2 Commits

Author SHA1 Message Date
Mplan 662609a78e refactor(ui): group menu by category and unify file selection
Build / bun-build (pull_request) Successful in 33s
Build / bun-build (push) Successful in 2m24s
2026-06-16 23:46:19 +08:00
Mplan df10ce5b0e docs: update README menu layout to grouped categories 2026-06-16 23:42:16 +08:00
11 changed files with 224 additions and 148 deletions
+18 -15
View File
@@ -121,23 +121,26 @@ git diff | gai suggest branch
Run `gai` without arguments to open the mole-style interactive menu: Run `gai` without arguments to open the mole-style interactive menu:
``` ```
██████╗ █████╗ ██╗ gai v0.1.3
██╔════╝ ██╔══██╗██║ AI-powered git helper for commits, PRs, reviews, and changelogs
██║ ██╗ ███████║██║ ────────────────────────────────────────────────────────────────────────
██║ ██║ ██╔══██║██║
╚██████╝ ██║ ██║██║ AI-powered git helper
╚═════╝ ╚═╝ ╚═╝╚═╝ v0.1.3
➤ 1. Commit Generate AI commit message CREATE
2. PR Create a PR with AI-generated title 1 Commit Generate AI commit message
3. Explain Explain staged changes in plain language 2 PR Create a PR with AI-generated title
4. Review AI code review of staged changes 3 Amend Amend last commit with AI message
5. Changelog Generate changelog from commits
6. Suggest Suggest branch name or commit type
7. Amend Amend last commit with AI message
8. Config Configure API settings
↑↓ | Enter | H Help | V Version | Q Quit INSPECT
4 Explain Explain staged changes in plain language
5 Review AI code review of staged changes
6 Changelog Generate changelog from commits
7 Suggest Suggest branch name or commit type
PROJECT
8 Config Configure API settings
────────────────────────────────────────────────────────────────────────
↑/↓ navigate enter run 1-8 jump h help v version q quit
``` ```
Number keys `1``8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu. Number keys `1``8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu.
+64 -45
View File
@@ -14,7 +14,7 @@ import { handleSuggest } from "./src/commands/suggest";
import { setColorEnabled } from "./src/terminal"; import { setColorEnabled } from "./src/terminal";
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal"; import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty"; import { isStdinTTY, initTTY } from "./src/tty";
import { showBanner } from "./src/brand"; import { VERSION } from "./src/brand";
import { SKIP_WAIT } from "./src/menu"; import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (mole-style) ───────────────────────────────────── // ── Interactive Menu (mole-style) ─────────────────────────────────────
@@ -23,17 +23,18 @@ interface MenuItem {
key: string; key: string;
label: string; label: string;
description: string; description: string;
group: "Create" | "Inspect" | "Project";
} }
const MENU_ITEMS: MenuItem[] = [ const MENU_ITEMS: MenuItem[] = [
{ key: "commit", label: "Commit", description: "Generate AI commit message" }, { key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title" }, { key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language" }, { key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
{ key: "review", label: "Review", description: "AI code review of staged changes" }, { key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits" }, { key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" }, { key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message" }, { key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
{ key: "config", label: "Config", description: "Configure API settings" }, { key: "config", label: "Config", description: "Configure API settings", group: "Project" },
]; ];
function hideCursor() { process.stdout.write("\x1b[?25l"); } function hideCursor() { process.stdout.write("\x1b[?25l"); }
@@ -55,51 +56,70 @@ function visibleLen(s: string): number {
return s.replace(/\x1b\[[0-9;]*m/g, "").length; return s.replace(/\x1b\[[0-9;]*m/g, "").length;
} }
function renderMenu(banner: string, cursor: number): number { function padRight(value: string, width: number): string {
return value + " ".repeat(Math.max(0, width - visibleLen(value)));
}
function renderMenu(cursor: number): number {
process.stdout.write("\x1b[H"); // cursor home process.stdout.write("\x1b[H"); // cursor home
let lineCount = 0; let lineCount = 0;
for (const line of banner.split("\n")) {
clearLine();
process.stdout.write(line + "\n");
lineCount++;
}
const G = GREEN();
const C = CYAN(); const C = CYAN();
const D = DIM(); const D = DIM();
const G = GREEN();
const R = RESET(); const R = RESET();
const ARROW = "➤"; const width = 72;
const separator = `${D}${"─".repeat(width)}${R}`;
// Calculate padding const write = (line = "") => {
clearLine();
process.stdout.write(`${line}\n`);
lineCount++;
};
write("");
write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`);
write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`);
write(` ${separator}`);
write("");
const keyWidth = 3;
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2; const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
let currentGroup: MenuItem["group"] | null = null;
for (let i = 0; i < MENU_ITEMS.length; i++) { for (let i = 0; i < MENU_ITEMS.length; i++) {
const item = MENU_ITEMS[i]!; const item = MENU_ITEMS[i]!;
const num = String(i + 1); const num = String(i + 1);
const active = i === cursor; const active = i === cursor;
clearLine(); if (item.group !== currentGroup) {
if (active) { if (currentGroup !== null) write("");
const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label))); write(` ${D}${item.group.toUpperCase()}${R}`);
process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`); currentGroup = item.group;
} else { }
const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3);
process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`); const pointer = active ? `${C}${R}` : " ";
const key = active ? `${G}${num}${R}` : `${D}${num}${R}`;
const label = active ? `${BOLD()}${item.label}${R}` : item.label;
const description = active ? item.description : `${D}${item.description}${R}`;
const row = [
pointer,
padRight(key, keyWidth),
padRight(label, labelWidth),
description,
].join(" ");
if (active) {
write(` ${C}${R} ${row}`);
} else {
write(` ${row}`);
} }
lineCount++;
} }
// Footer write("");
clearLine(); write(` ${separator}`);
process.stdout.write("\n"); write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`);
lineCount++; write("");
clearLine();
process.stdout.write(` ${D}↑↓ | Enter | ${G}H${D} Help | ${G}V${D} Version | ${G}Q${D} Quit${R}\n`);
lineCount++;
clearLine();
process.stdout.write("\n");
lineCount++;
// Clear rest of screen // Clear rest of screen
process.stdout.write("\x1b[J"); process.stdout.write("\x1b[J");
@@ -167,7 +187,6 @@ async function showMenu(): Promise<number> {
return 1; return 1;
} }
const banner = showBanner();
let cursor = 0; let cursor = 0;
const wasRaw = process.stdin.isRaw; const wasRaw = process.stdin.isRaw;
@@ -176,7 +195,7 @@ async function showMenu(): Promise<number> {
hideCursor(); hideCursor();
// Initial render // Initial render
renderMenu(banner, cursor); renderMenu(cursor);
try { try {
while (true) { while (true) {
@@ -184,11 +203,11 @@ async function showMenu(): Promise<number> {
// Escape sequences (arrows) // Escape sequences (arrows)
if (raw === "\x1b[A" || raw === "\x1bOA") { if (raw === "\x1b[A" || raw === "\x1bOA") {
if (cursor > 0) { cursor--; renderMenu(banner, cursor); } if (cursor > 0) { cursor--; renderMenu(cursor); }
continue; continue;
} }
if (raw === "\x1b[B" || raw === "\x1bOB") { if (raw === "\x1b[B" || raw === "\x1bOB") {
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); } if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); }
continue; continue;
} }
@@ -199,7 +218,7 @@ async function showMenu(): Promise<number> {
hideCursor(); hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
renderMenu(banner, cursor); renderMenu(cursor);
continue; continue;
} }
@@ -217,13 +236,13 @@ async function showMenu(): Promise<number> {
const idx = parseInt(raw) - 1; const idx = parseInt(raw) - 1;
if (idx < MENU_ITEMS.length) { if (idx < MENU_ITEMS.length) {
cursor = idx; cursor = idx;
renderMenu(banner, cursor); renderMenu(cursor);
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw); const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
if (result !== 0) return result; if (result !== 0) return result;
hideCursor(); hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
renderMenu(banner, cursor); renderMenu(cursor);
continue; continue;
} }
} }
@@ -243,7 +262,7 @@ async function showMenu(): Promise<number> {
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H"); process.stdout.write("\x1b[2J\x1b[H");
console.log("gai v0.1.3"); console.log(`gai v${VERSION}`);
return 0; return 0;
} }
if (lower === "q") { if (lower === "q") {
+1 -1
View File
@@ -2,7 +2,7 @@
import { GREEN, CYAN, RESET } from "./terminal"; import { GREEN, CYAN, RESET } from "./terminal";
const VERSION = "0.1.3"; export const VERSION = "0.1.3";
export function showBanner(): string { export function showBanner(): string {
const G = GREEN(); const G = GREEN();
+1 -1
View File
@@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks = tty ? { const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+35 -20
View File
@@ -7,6 +7,7 @@ import {
getStagedDiff, getStagedDiff,
getRecentCommits, getRecentCommits,
stageFiles, stageFiles,
applyFileSelection,
commit, commit,
} from "../git"; } from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
@@ -50,6 +51,19 @@ function printCommitResult(result: CommitResult, msg: string) {
if (parts.length > 0) console.log(` ${parts.join(", ")}`); if (parts.length > 0) console.log(` ${parts.join(", ")}`);
} }
function printSelectionResult(result: { staged: string[]; unstaged: string[] }) {
const parts: string[] = [];
if (result.staged.length > 0) {
parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`);
}
if (result.unstaged.length > 0) {
parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`);
}
if (parts.length > 0) {
console.log(` Updated staging area: ${parts.join(", ")}`);
}
}
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`); console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
console.log(` ${GREEN()}${message}${RESET()}\n`); console.log(` ${GREEN()}${message}${RESET()}\n`);
@@ -173,16 +187,22 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
const stagedFiles = await getStagedFiles(); const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles(); const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && !amend) { if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) {
if (autoMode && unstagedFiles.length > 0) { console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
await stageFiles(unstagedFiles.map((f) => f.path)); return 1;
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); }
} else if (unstagedFiles.length > 0) {
if (!amend && autoMode && unstagedFiles.length > 0) {
await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else if (!amend) {
if (isStdinTTY()) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
} else if (stagedFiles.length === 0 && unstagedFiles.length > 0) {
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`); console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
return 1; return 1;
} else {
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
return 1;
} }
} }
@@ -215,18 +235,13 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
return 0; return 0;
} }
if (unstagedFiles.length > 0) { if (autoMode && unstagedFiles.length > 0) {
if (autoMode) { await stageFiles(unstagedFiles.map((f) => f.path));
await stageFiles(unstagedFiles.map((f) => f.path)); console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); } else {
} else { const selected = await selectFiles(stagedFiles, unstagedFiles);
const selected = await selectFiles(stagedFiles, unstagedFiles); if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected === BACK) return SKIP_WAIT as unknown as number; printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
}
}
} }
const diff = await getStagedDiff(); const diff = await getStagedDiff();
+17 -17
View File
@@ -1,5 +1,12 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git"; import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
@@ -19,7 +26,6 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
} }
const unstaged = args.flags["unstaged"] as boolean; const unstaged = args.flags["unstaged"] as boolean;
const staged = args.flags["staged"] as boolean;
const verbose = args.flags["verbose"] as boolean; const verbose = args.flags["verbose"] as boolean;
// Determine which diff to explain // Determine which diff to explain
@@ -44,22 +50,16 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1; return 1;
} }
diff = await getStagedDiff(); const stagedFiles = await getStagedFiles();
sourceLabel = "staged changes"; const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
// If no staged changes, offer to stage unstaged files if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
if (!diff) { const selected = await selectFiles(stagedFiles, unstagedFiles);
const unstagedFiles = await getUnstagedFiles(); if (selected === BACK) return SKIP_WAIT as unknown as number;
if (unstagedFiles.length > 0) { await applyFileSelection(stagedFiles, unstagedFiles, selected);
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
} }
diff = await getStagedDiff();
} else { } else {
// Read from pipe // Read from pipe
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -109,7 +109,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks = tty ? { const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+17 -16
View File
@@ -1,5 +1,12 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git"; import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
@@ -53,22 +60,16 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1; return 1;
} }
diff = await getStagedDiff(); const stagedFiles = await getStagedFiles();
sourceLabel = "staged changes"; const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
// If no staged changes, offer to stage unstaged files if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
if (!diff) { const selected = await selectFiles(stagedFiles, unstagedFiles);
const unstagedFiles = await getUnstagedFiles(); if (selected === BACK) return SKIP_WAIT as unknown as number;
if (unstagedFiles.length > 0) { await applyFileSelection(stagedFiles, unstagedFiles, selected);
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
} }
diff = await getStagedDiff();
} }
if (!diff) { if (!diff) {
@@ -110,7 +111,7 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks = tty ? { const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+14 -14
View File
@@ -1,5 +1,11 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git"; import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } from "../menu";
import { import {
@@ -51,21 +57,15 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
diff = ""; diff = "";
} }
} else { } else {
diff = await getStagedDiff(); const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
// If no staged changes, offer to stage unstaged files if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
if (!diff) { const selected = await selectFiles(stagedFiles, unstagedFiles);
const unstagedFiles = await getUnstagedFiles(); if (selected === BACK) return SKIP_WAIT as unknown as number;
if (unstagedFiles.length > 0) { await applyFileSelection(stagedFiles, unstagedFiles, selected);
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
} }
diff = await getStagedDiff();
} }
} }
+27
View File
@@ -98,6 +98,33 @@ export async function stageFiles(paths: string[]): Promise<void> {
await Bun.$`git add -- ${paths}`; await Bun.$`git add -- ${paths}`;
} }
export async function unstageFiles(paths: string[]): Promise<void> {
if (paths.length === 0) return;
try {
await Bun.$`git restore --staged -- ${paths}`.quiet();
} catch {
await Bun.$`git rm --cached -r -- ${paths}`.quiet();
}
}
export async function applyFileSelection(
stagedFiles: FileEntry[],
unstagedFiles: FileEntry[],
selectedPaths: string[],
): Promise<{ staged: string[]; unstaged: string[] }> {
const selected = new Set(selectedPaths);
const stagedPaths = new Set(stagedFiles.map((file) => file.path));
const unstagedPaths = new Set(unstagedFiles.map((file) => file.path));
const toUnstage = [...stagedPaths].filter((path) => !selected.has(path));
const toStage = [...selected].filter((path) => unstagedPaths.has(path));
await unstageFiles(toUnstage);
await stageFiles(toStage);
return { staged: toStage, unstaged: toUnstage };
}
export async function commit( export async function commit(
message: string, message: string,
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> { ): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
+1
View File
@@ -225,6 +225,7 @@ export async function selectMany<T>(
if (!options.selectAllLabel) return; if (!options.selectAllLabel) return;
items[0]!.selected = items.slice(1).every((item) => item.selected); items[0]!.selected = items.slice(1).every((item) => item.selected);
}; };
syncSelectAll();
const toggle = (index: number) => { const toggle = (index: number) => {
const item = items[index]!; const item = items[index]!;
+29 -19
View File
@@ -1,44 +1,54 @@
import type { FileEntry } from "./types"; import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { isStdinTTY } from "./tty"; import { isStdinTTY } from "./tty";
import { BACK, selectMany } from "./menu"; import { BACK, selectMany } from "./menu";
import type { PromptBack } from "./menu"; import type { PromptBack } from "./menu";
function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) {
const files = new Map<string, FileEntry & { staged: boolean; unstaged: boolean }>();
for (const file of stagedFiles) {
files.set(file.path, { ...file, staged: true, unstaged: false });
}
for (const file of unstagedFiles) {
const existing = files.get(file.path);
if (existing) {
existing.unstaged = true;
existing.label = existing.label === file.label
? existing.label
: `${existing.label}, ${file.label}`;
} else {
files.set(file.path, { ...file, staged: false, unstaged: true });
}
}
return [...files.values()];
}
export async function selectFiles( export async function selectFiles(
stagedFiles: FileEntry[], stagedFiles: FileEntry[],
unstagedFiles: FileEntry[], unstagedFiles: FileEntry[],
): Promise<string[] | PromptBack> { ): Promise<string[] | PromptBack> {
if (unstagedFiles.length === 0) return []; const files = mergeFiles(stagedFiles, unstagedFiles);
if (files.length === 0) return [];
if (stagedFiles.length > 0) { if (!isStdinTTY()) return stagedFiles.map((file) => file.path);
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`);
}
}
if (!isStdinTTY()) return [];
const selected = await selectMany({ const selected = await selectMany({
title: "Select files to stage", title: "Select files for this action",
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`, subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`,
selectAllLabel: "Select all", selectAllLabel: "Select all",
cancelMessage: "Aborted.", cancelMessage: "Aborted.",
items: unstagedFiles.map((f) => ({ items: files.map((f) => ({
label: f.path, label: f.path,
value: f.path, value: f.path,
description: f.label, description: f.label,
selected: f.staged,
})), })),
}); });
if (selected === null) process.exit(1); if (selected === null) process.exit(1);
if (selected === BACK) return BACK; if (selected === BACK) return BACK;
if (selected.length > 0) {
process.stdout.write(
` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
);
}
return selected; return selected;
} }