refactor(cli): extract interactive menu into reusable module (#3)
Extract duplicate menu rendering logic from `index.ts` into a new `src/menu.ts` module, providing generic `selectOne` and `selectMany` functions. This reduces code duplication, improves maintainability, and adds consistent UI controls display across the commit flow and platform selection. Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -60,10 +60,12 @@ gai --version Show version
|
|||||||
$ gai
|
$ gai
|
||||||
|
|
||||||
gai
|
gai
|
||||||
|
Choose a workflow
|
||||||
|
↑/↓ navigate · enter/space select · ctrl+c cancel
|
||||||
|
|
||||||
↑/↓ navigate, space/enter select
|
❯ ● commit Generate AI commit message
|
||||||
|
○ pr Create a PR with AI-generated title
|
||||||
❯ ◉ commit Generate AI commit message
|
○ config Configure API settings
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit Flow
|
### Commit Flow
|
||||||
@@ -74,14 +76,13 @@ $ gai commit
|
|||||||
Staged files (will be included):
|
Staged files (will be included):
|
||||||
✓ src/git.ts (modified)
|
✓ src/git.ts (modified)
|
||||||
|
|
||||||
Unstaged files:
|
|
||||||
1. src/ai.ts (modified)
|
|
||||||
2. src/newfile.ts (new)
|
|
||||||
|
|
||||||
Select files to stage:
|
Select files to stage:
|
||||||
❯ ◉ Select all
|
2 unstaged files available
|
||||||
○ src/ai.ts (modified)
|
↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel
|
||||||
◉ src/newfile.ts (new)
|
|
||||||
|
❯ □ Select all
|
||||||
|
□ src/ai.ts modified
|
||||||
|
■ src/newfile.ts new
|
||||||
|
|
||||||
Generating commit message...
|
Generating commit message...
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
commit,
|
commit,
|
||||||
} from "./src/git";
|
} from "./src/git";
|
||||||
import { selectFiles } from "./src/selector";
|
import { selectFiles } from "./src/selector";
|
||||||
|
import { selectOne } from "./src/menu";
|
||||||
import { collectProjectContext } from "./src/context";
|
import { collectProjectContext } from "./src/context";
|
||||||
import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt";
|
import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt";
|
||||||
import { generateCommitMessage } from "./src/ai";
|
import { generateCommitMessage } from "./src/ai";
|
||||||
@@ -286,7 +287,7 @@ function printCommitResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MenuAction {
|
interface MenuAction {
|
||||||
key: string;
|
key: "commit" | "pr" | "config";
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
@@ -298,233 +299,39 @@ const MENU_ACTIONS: MenuAction[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function showMenu(): Promise<void> {
|
async function showMenu(): Promise<void> {
|
||||||
const actions = MENU_ACTIONS;
|
|
||||||
let cursor = 0;
|
|
||||||
|
|
||||||
const headerLines = 4;
|
|
||||||
|
|
||||||
process.stdout.write(`\n ${BOLD}gai${RESET}\n`);
|
|
||||||
process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`);
|
|
||||||
|
|
||||||
const totalLines = headerLines + actions.length;
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
for (let i = 0; i < actions.length; i++) {
|
|
||||||
process.stdout.write("\x1b[2K\r");
|
|
||||||
const a = actions[i]!;
|
|
||||||
const pointer = i === cursor ? `${CYAN}❯${RESET} ` : " ";
|
|
||||||
const dot = i === cursor ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`;
|
|
||||||
const name = i === cursor ? `${BOLD}${a.label}${RESET}` : a.label;
|
|
||||||
const desc = i === cursor ? a.description : `${DIM}${a.description}${RESET}`;
|
|
||||||
process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 14 - a.label.length))}${desc}\n`);
|
|
||||||
}
|
|
||||||
process.stdout.write(`\x1b[${actions.length}A`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMenu() {
|
|
||||||
process.stdout.write(`\x1b[${headerLines}A`);
|
|
||||||
for (let i = 0; i < totalLines; i++) {
|
|
||||||
process.stdout.write("\r\x1b[2K\n");
|
|
||||||
}
|
|
||||||
process.stdout.write(`\x1b[${totalLines}A`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.stdin.isTTY) {
|
if (!process.stdin.isTTY) {
|
||||||
console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`);
|
console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedRaw = process.stdin.isRaw;
|
const selected = await selectOne({
|
||||||
process.stdin.setRawMode(true);
|
title: "gai",
|
||||||
process.stdin.resume();
|
subtitle: "Choose a workflow",
|
||||||
process.stdout.write("\x1b[?25l");
|
items: MENU_ACTIONS.map((action) => ({
|
||||||
|
label: action.label,
|
||||||
render();
|
value: action.key,
|
||||||
|
description: action.description,
|
||||||
return new Promise((resolve) => {
|
})),
|
||||||
let escapeBuf = "";
|
|
||||||
|
|
||||||
function handleSeq(seq: string) {
|
|
||||||
if (seq === "\x1b[A" || seq === "\x1bOA") {
|
|
||||||
if (cursor > 0) {
|
|
||||||
cursor--;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
} else if (seq === "\x1b[B" || seq === "\x1bOB") {
|
|
||||||
if (cursor < actions.length - 1) {
|
|
||||||
cursor++;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdin.on("data", (data: Buffer) => {
|
|
||||||
const key = data.toString();
|
|
||||||
|
|
||||||
if (key === "\x03") {
|
|
||||||
process.stdin.setRawMode(savedRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeAllListeners("data");
|
|
||||||
clearMenu();
|
|
||||||
process.stdout.write("\x1b[?25h");
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "\x1b" || key.startsWith("\x1b[")) {
|
|
||||||
escapeBuf = key;
|
|
||||||
if (key.length >= 3) {
|
|
||||||
handleSeq(key);
|
|
||||||
escapeBuf = "";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (escapeBuf) {
|
|
||||||
escapeBuf += key;
|
|
||||||
if (/^[A-Za-z~]$/.test(key)) {
|
|
||||||
handleSeq(escapeBuf);
|
|
||||||
escapeBuf = "";
|
|
||||||
} else if (escapeBuf.length > 8) {
|
|
||||||
escapeBuf = "";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === " " || key === "\r") {
|
|
||||||
const selected = actions[cursor]!;
|
|
||||||
process.stdin.setRawMode(savedRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeAllListeners("data");
|
|
||||||
|
|
||||||
clearMenu();
|
|
||||||
process.stdout.write("\x1b[?25h");
|
|
||||||
|
|
||||||
if (selected.key === "commit") {
|
|
||||||
handleCommit(false, false).then(resolve);
|
|
||||||
} else if (selected.key === "pr") {
|
|
||||||
handlePR(false).then(resolve);
|
|
||||||
} else if (selected.key === "config") {
|
|
||||||
handleConfig().then(resolve);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (selected === "commit") await handleCommit(false, false);
|
||||||
|
if (selected === "pr") await handlePR(false);
|
||||||
|
if (selected === "config") await handleConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectPlatform(hostname: string): Promise<Platform | null> {
|
async function selectPlatform(hostname: string): Promise<Platform | null> {
|
||||||
const options = [
|
|
||||||
{ platform: "github" as Platform, label: "GitHub", desc: "gh CLI" },
|
|
||||||
{ platform: "gitea" as Platform, label: "Gitea", desc: "tea CLI" },
|
|
||||||
];
|
|
||||||
let cursor = 0;
|
|
||||||
|
|
||||||
const headerLines = 4;
|
|
||||||
|
|
||||||
process.stdout.write(`\n Remote: ${CYAN}${hostname}${RESET} — could not auto-detect platform.\n`);
|
|
||||||
process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`);
|
|
||||||
|
|
||||||
const totalLines = headerLines + options.length;
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
for (let i = 0; i < options.length; i++) {
|
|
||||||
process.stdout.write("\x1b[2K\r");
|
|
||||||
const opt = options[i]!;
|
|
||||||
const pointer = i === cursor ? `${CYAN}❯${RESET} ` : " ";
|
|
||||||
const dot = i === cursor ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`;
|
|
||||||
const name = i === cursor ? `${BOLD}${opt.label}${RESET}` : opt.label;
|
|
||||||
const desc = i === cursor ? opt.desc : `${DIM}${opt.desc}${RESET}`;
|
|
||||||
process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 10 - opt.label.length))}${desc}\n`);
|
|
||||||
}
|
|
||||||
process.stdout.write(`\x1b[${options.length}A`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMenu() {
|
|
||||||
process.stdout.write(`\x1b[${headerLines}A`);
|
|
||||||
for (let i = 0; i < totalLines; i++) {
|
|
||||||
process.stdout.write("\r\x1b[2K\n");
|
|
||||||
}
|
|
||||||
process.stdout.write(`\x1b[${totalLines}A`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.stdin.isTTY) {
|
if (!process.stdin.isTTY) {
|
||||||
console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`);
|
console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedRaw = process.stdin.isRaw;
|
return selectOne({
|
||||||
process.stdin.setRawMode(true);
|
title: "Select remote platform",
|
||||||
process.stdin.resume();
|
subtitle: `Remote ${hostname} could not be auto-detected`,
|
||||||
process.stdout.write("\x1b[?25l");
|
items: [
|
||||||
|
{ label: "GitHub", value: "github" as Platform, description: "Use gh CLI" },
|
||||||
render();
|
{ label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" },
|
||||||
|
],
|
||||||
return new Promise((resolve) => {
|
|
||||||
let escapeBuf = "";
|
|
||||||
|
|
||||||
function handleSeq(seq: string) {
|
|
||||||
if (seq === "\x1b[A" || seq === "\x1bOA") {
|
|
||||||
if (cursor > 0) {
|
|
||||||
cursor--;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
} else if (seq === "\x1b[B" || seq === "\x1bOB") {
|
|
||||||
if (cursor < options.length - 1) {
|
|
||||||
cursor++;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdin.on("data", (data: Buffer) => {
|
|
||||||
const key = data.toString();
|
|
||||||
|
|
||||||
if (key === "\x03") {
|
|
||||||
process.stdin.setRawMode(savedRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeAllListeners("data");
|
|
||||||
clearMenu();
|
|
||||||
process.stdout.write("\x1b[?25h");
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "\x1b" || key.startsWith("\x1b[")) {
|
|
||||||
escapeBuf = key;
|
|
||||||
if (key.length >= 3) {
|
|
||||||
handleSeq(key);
|
|
||||||
escapeBuf = "";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (escapeBuf) {
|
|
||||||
escapeBuf += key;
|
|
||||||
if (/^[A-Za-z~]$/.test(key)) {
|
|
||||||
handleSeq(escapeBuf);
|
|
||||||
escapeBuf = "";
|
|
||||||
} else if (escapeBuf.length > 8) {
|
|
||||||
escapeBuf = "";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === " " || key === "\r") {
|
|
||||||
const selected = options[cursor]!;
|
|
||||||
process.stdin.setRawMode(savedRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeAllListeners("data");
|
|
||||||
|
|
||||||
clearMenu();
|
|
||||||
process.stdout.write("\x1b[?25h");
|
|
||||||
|
|
||||||
resolve(selected.platform);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
|
||||||
|
|
||||||
|
const UP = "\x1b[A";
|
||||||
|
const DOWN = "\x1b[B";
|
||||||
|
const ALT_UP = "\x1bOA";
|
||||||
|
const ALT_DOWN = "\x1bOB";
|
||||||
|
const SPACE = " ";
|
||||||
|
const ENTER = "\r";
|
||||||
|
const CTRL_C = "\x03";
|
||||||
|
|
||||||
|
export interface Choice<T> {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
description?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BasePromptOptions {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
cancelMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SinglePromptOptions<T> extends BasePromptOptions {
|
||||||
|
items: Choice<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiPromptOptions<T> extends BasePromptOptions {
|
||||||
|
items: Choice<T>[];
|
||||||
|
selectAllLabel?: string;
|
||||||
|
doneLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCursor() {
|
||||||
|
process.stdout.write("\x1b[?25l");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCursor() {
|
||||||
|
process.stdout.write("\x1b[?25h");
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveUp(lines: number) {
|
||||||
|
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLine() {
|
||||||
|
process.stdout.write("\r\x1b[2K");
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleLength(value: string) {
|
||||||
|
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padLabel(label: string, width: number) {
|
||||||
|
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function controls(mode: "single" | "multi") {
|
||||||
|
if (mode === "single") {
|
||||||
|
return `${DIM}↑/↓ navigate · enter/space select · ctrl+c cancel${RESET}`;
|
||||||
|
}
|
||||||
|
return `${DIM}↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel${RESET}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPrompt(lines: string[], previousLines: number) {
|
||||||
|
if (previousLines > 0) {
|
||||||
|
for (let i = 0; i < previousLines; i++) {
|
||||||
|
clearLine();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
}
|
||||||
|
moveUp(previousLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
process.stdout.write(`${line}\n`);
|
||||||
|
}
|
||||||
|
moveUp(lines.length);
|
||||||
|
return lines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPrompt(lines: number) {
|
||||||
|
for (let i = 0; i < lines; i++) {
|
||||||
|
clearLine();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
}
|
||||||
|
moveUp(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(key: string, escapeBuf: string) {
|
||||||
|
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||||
|
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||||
|
if (key === SPACE) return { action: "space", escapeBuf: "" };
|
||||||
|
if (key === ENTER) return { action: "enter", escapeBuf: "" };
|
||||||
|
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
|
||||||
|
|
||||||
|
if (key === "\x1b" || key.startsWith("\x1b[")) {
|
||||||
|
return { action: null, escapeBuf: key };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escapeBuf) {
|
||||||
|
const next = escapeBuf + key;
|
||||||
|
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||||
|
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||||
|
return {
|
||||||
|
action: null,
|
||||||
|
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: null, escapeBuf: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLines<T>(
|
||||||
|
options: BasePromptOptions & { items: Choice<T>[] },
|
||||||
|
mode: "single" | "multi",
|
||||||
|
cursor: number,
|
||||||
|
) {
|
||||||
|
const labelWidth = Math.max(
|
||||||
|
...options.items.map((item) => visibleLength(item.label)),
|
||||||
|
0,
|
||||||
|
) + 2;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
"",
|
||||||
|
` ${BOLD}${options.title}${RESET}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`);
|
||||||
|
lines.push(` ${controls(mode)}`, "");
|
||||||
|
|
||||||
|
for (let i = 0; i < options.items.length; i++) {
|
||||||
|
const item = options.items[i]!;
|
||||||
|
const active = i === cursor;
|
||||||
|
const pointer = active ? `${CYAN}❯${RESET}` : " ";
|
||||||
|
const marker = mode === "single"
|
||||||
|
? active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`
|
||||||
|
: item.selected ? `${GREEN}◼${RESET}` : `${DIM}□${RESET}`;
|
||||||
|
const label = active ? `${BOLD}${item.label}${RESET}` : item.label;
|
||||||
|
const description = item.description
|
||||||
|
? active ? item.description : `${DIM}${item.description}${RESET}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTTY(title: string) {
|
||||||
|
if (process.stdin.isTTY !== true) {
|
||||||
|
throw new Error(`${title} requires a TTY.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectOne<T>(
|
||||||
|
options: SinglePromptOptions<T>,
|
||||||
|
): Promise<T | null> {
|
||||||
|
ensureTTY(options.title);
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
let renderedLines = 0;
|
||||||
|
let escapeBuf = "";
|
||||||
|
const wasRaw = process.stdin.isRaw;
|
||||||
|
|
||||||
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
hideCursor();
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
renderedLines = renderPrompt(
|
||||||
|
createLines(options, "single", cursor),
|
||||||
|
renderedLines,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = (value: T | null) => {
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdin.removeListener("data", onData);
|
||||||
|
clearPrompt(renderedLines);
|
||||||
|
showCursor();
|
||||||
|
if (value === null && options.cancelMessage) {
|
||||||
|
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
const result = normalizeKey(data.toString(), escapeBuf);
|
||||||
|
escapeBuf = result.escapeBuf;
|
||||||
|
|
||||||
|
if (result.action === "cancel") return finish(null);
|
||||||
|
if (result.action === "up" && cursor > 0) {
|
||||||
|
cursor--;
|
||||||
|
render();
|
||||||
|
} else if (result.action === "down" && cursor < options.items.length - 1) {
|
||||||
|
cursor++;
|
||||||
|
render();
|
||||||
|
} else if (result.action === "space" || result.action === "enter") {
|
||||||
|
finish(options.items[cursor]!.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdin.on("data", onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectMany<T>(
|
||||||
|
options: MultiPromptOptions<T>,
|
||||||
|
): Promise<T[] | null> {
|
||||||
|
ensureTTY(options.title);
|
||||||
|
|
||||||
|
const items: Choice<T | null>[] = options.selectAllLabel
|
||||||
|
? [
|
||||||
|
{ label: options.selectAllLabel, value: null, selected: false },
|
||||||
|
...options.items.map((item) => ({ ...item })),
|
||||||
|
]
|
||||||
|
: options.items.map((item) => ({ ...item }));
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
let renderedLines = 0;
|
||||||
|
let escapeBuf = "";
|
||||||
|
const wasRaw = process.stdin.isRaw;
|
||||||
|
|
||||||
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
hideCursor();
|
||||||
|
|
||||||
|
const syncSelectAll = () => {
|
||||||
|
if (!options.selectAllLabel) return;
|
||||||
|
items[0]!.selected = items.slice(1).every((item) => item.selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (index: number) => {
|
||||||
|
const item = items[index]!;
|
||||||
|
item.selected = !item.selected;
|
||||||
|
|
||||||
|
if (options.selectAllLabel && index === 0) {
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
items[i]!.selected = item.selected;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syncSelectAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
renderedLines = renderPrompt(
|
||||||
|
createLines(
|
||||||
|
{
|
||||||
|
title: options.title,
|
||||||
|
subtitle: options.subtitle,
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
"multi",
|
||||||
|
cursor,
|
||||||
|
),
|
||||||
|
renderedLines,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = (value: T[] | null) => {
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdin.removeListener("data", onData);
|
||||||
|
clearPrompt(renderedLines);
|
||||||
|
showCursor();
|
||||||
|
if (value === null && options.cancelMessage) {
|
||||||
|
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
const result = normalizeKey(data.toString(), escapeBuf);
|
||||||
|
escapeBuf = result.escapeBuf;
|
||||||
|
|
||||||
|
if (result.action === "cancel") return finish(null);
|
||||||
|
if (result.action === "up" && cursor > 0) {
|
||||||
|
cursor--;
|
||||||
|
render();
|
||||||
|
} else if (result.action === "down" && cursor < items.length - 1) {
|
||||||
|
cursor++;
|
||||||
|
render();
|
||||||
|
} else if (result.action === "space") {
|
||||||
|
toggle(cursor);
|
||||||
|
render();
|
||||||
|
} else if (result.action === "enter") {
|
||||||
|
finish(
|
||||||
|
items
|
||||||
|
.filter((item) => item.selected && item.value !== null)
|
||||||
|
.map((item) => item.value as T),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdin.on("data", onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ export async function createPR(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
||||||
return match ? match[1] : stdout.trim();
|
return match?.[1] ?? stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
@@ -180,5 +180,5 @@ export async function createPR(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
||||||
return match ? match[1] : stdout.trim();
|
return match?.[1] ?? stdout.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-150
@@ -1,37 +1,6 @@
|
|||||||
import type { FileEntry } from "./types";
|
import type { FileEntry } from "./types";
|
||||||
import { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal";
|
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
|
||||||
|
import { selectMany } from "./menu";
|
||||||
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(
|
export async function selectFiles(
|
||||||
stagedFiles: FileEntry[],
|
stagedFiles: FileEntry[],
|
||||||
@@ -46,123 +15,27 @@ export async function selectFiles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: SelectItem[] = [
|
if (process.stdin.isTTY !== true) return [];
|
||||||
{ label: "Select all", path: "__all__", selected: false },
|
|
||||||
...unstagedFiles.map((f) => ({
|
const selected = await selectMany({
|
||||||
label: `${f.path} (${f.label})`,
|
title: "Select files to stage",
|
||||||
path: f.path,
|
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
|
||||||
selected: false,
|
selectAllLabel: "Select all",
|
||||||
|
cancelMessage: "Aborted.",
|
||||||
|
items: unstagedFiles.map((f) => ({
|
||||||
|
label: f.path,
|
||||||
|
value: f.path,
|
||||||
|
description: f.label,
|
||||||
})),
|
})),
|
||||||
];
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (selected === null) process.exit(1);
|
||||||
|
|
||||||
|
if (selected.length > 0) {
|
||||||
|
process.stdout.write(
|
||||||
|
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user