169 lines
4.2 KiB
TypeScript
169 lines
4.2 KiB
TypeScript
import type { FileEntry } from "./types";
|
||
import { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal";
|
||
|
||
const UP = "\x1b[A";
|
||
const DOWN = "\x1b[B";
|
||
const SPACE = " ";
|
||
const ENTER = "\r";
|
||
const CTRL_C = "\x03";
|
||
|
||
function hideCursor() {
|
||
process.stdout.write("\x1b[?25l");
|
||
}
|
||
|
||
function showCursor() {
|
||
process.stdout.write("\x1b[?25h");
|
||
}
|
||
|
||
function moveUp(n: number) {
|
||
process.stdout.write(`\x1b[${n}A`);
|
||
}
|
||
|
||
function moveDown(n: number) {
|
||
process.stdout.write(`\x1b[${n}B`);
|
||
}
|
||
|
||
function clearLine() {
|
||
process.stdout.write("\x1b[2K\r");
|
||
}
|
||
|
||
interface SelectItem {
|
||
label: string;
|
||
path: string;
|
||
selected: boolean;
|
||
}
|
||
|
||
export async function selectFiles(
|
||
stagedFiles: FileEntry[],
|
||
unstagedFiles: FileEntry[],
|
||
): Promise<string[]> {
|
||
if (unstagedFiles.length === 0) return [];
|
||
|
||
if (stagedFiles.length > 0) {
|
||
process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`);
|
||
for (const f of stagedFiles) {
|
||
process.stdout.write(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`);
|
||
}
|
||
}
|
||
|
||
const items: SelectItem[] = [
|
||
{ label: "Select all", path: "__all__", selected: false },
|
||
...unstagedFiles.map((f) => ({
|
||
label: `${f.path} (${f.label})`,
|
||
path: f.path,
|
||
selected: false,
|
||
})),
|
||
];
|
||
|
||
let cursor = 0;
|
||
|
||
process.stdout.write(`\n ${BOLD}Select files to stage:${RESET}\n`);
|
||
process.stdout.write(` ${DIM}↑/↓ navigate, space select, enter confirm${RESET}\n\n`);
|
||
|
||
const itemStartRow = 4 + (stagedFiles.length > 0 ? stagedFiles.length + 2 : 0);
|
||
|
||
function render() {
|
||
for (let i = 0; i < items.length; i++) {
|
||
process.stdout.write("\x1b[2K\r");
|
||
const item = items[i]!;
|
||
const isAll = i === 0;
|
||
const cursor_ = i === cursor ? `${CYAN}❯${RESET} ` : " ";
|
||
const checkbox = item.selected ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`;
|
||
|
||
if (isAll) {
|
||
process.stdout.write(`${cursor_} ${checkbox} ${BOLD}${item.label}${RESET}\n`);
|
||
} else {
|
||
process.stdout.write(`${cursor_} ${checkbox} ${item.path.includes("(") ? item.label : `${item.label}`}\n`);
|
||
}
|
||
}
|
||
moveUp(items.length);
|
||
}
|
||
|
||
function toggleItem(index: number) {
|
||
const item = items[index]!;
|
||
item.selected = !item.selected;
|
||
|
||
if (index === 0) {
|
||
for (let i = 1; i < items.length; i++) {
|
||
items[i]!.selected = item.selected;
|
||
}
|
||
} else {
|
||
const allSelected = items.slice(1).every((it) => it.selected);
|
||
items[0]!.selected = allSelected;
|
||
}
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
if (process.stdin.isTTY !== true) {
|
||
resolve([]);
|
||
return;
|
||
}
|
||
|
||
const wasRaw = process.stdin.isRaw;
|
||
if (wasRaw !== true) {
|
||
process.stdin.setRawMode(true);
|
||
}
|
||
process.stdin.resume();
|
||
hideCursor();
|
||
|
||
render();
|
||
|
||
const onData = (data: Buffer) => {
|
||
const key = data.toString();
|
||
|
||
if (key === CTRL_C) {
|
||
process.stdin.setRawMode(wasRaw === true);
|
||
process.stdin.pause();
|
||
process.stdin.removeListener("data", onData);
|
||
showCursor();
|
||
for (let i = 0; i < items.length; i++) {
|
||
process.stdout.write("\x1b[2K\n");
|
||
}
|
||
moveUp(items.length);
|
||
process.stdout.write(`\n Aborted.\n`);
|
||
process.exit(1);
|
||
}
|
||
|
||
if (key === UP) {
|
||
if (cursor > 0) {
|
||
cursor--;
|
||
render();
|
||
}
|
||
} else if (key === DOWN) {
|
||
if (cursor < items.length - 1) {
|
||
cursor++;
|
||
render();
|
||
}
|
||
} else if (key === SPACE) {
|
||
toggleItem(cursor);
|
||
render();
|
||
} else if (key === ENTER) {
|
||
process.stdin.setRawMode(wasRaw === true);
|
||
process.stdin.pause();
|
||
process.stdin.removeListener("data", onData);
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
process.stdout.write("\x1b[2K\n");
|
||
}
|
||
moveUp(items.length);
|
||
|
||
const selected = items
|
||
.slice(1)
|
||
.filter((it) => it.selected)
|
||
.map((it) => it.path);
|
||
|
||
if (selected.length > 0) {
|
||
process.stdout.write(
|
||
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
|
||
);
|
||
}
|
||
|
||
showCursor();
|
||
resolve(selected);
|
||
}
|
||
};
|
||
|
||
process.stdin.on("data", onData);
|
||
});
|
||
}
|