diff --git a/src/commands/list.ts b/src/commands/list.ts index 30ee151..57346b5 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -2,7 +2,46 @@ import { homedir } from "os" import * as git from "../git.ts" import * as vm from "../vm.ts" import * as state from "../state.ts" -import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" +import { die, reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" + +// ── Shared rendering ───────────────────────────────────────────────── + +const styleDefs: [string, string, string][] = [ + ["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"], + ["saved", green, "●"], ["review", magenta, "⊛"], +] +const styles = Object.fromEntries( + styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }]) +) + +interface DisplaySession { + branch: string + prompt?: string + key: string +} + +function renderSessions(sessions: DisplaySession[], statuses: Record) { + const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length)) + const cols = process.stdout.columns || 80 + const prefixWidth = branchWidth + 4 + + console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) + + for (const s of sessions) { + const prompt = (s.prompt ?? "").split("\n")[0] + const status = statuses[s.key] ?? "idle" + const { icon, color: bc } = styles[status] + const maxPrompt = cols - prefixWidth + const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt + console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) + } +} + +function renderLegend() { + console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`) +} + +// ── Commands ───────────────────────────────────────────────────────── export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) { if (opts.add) return actionAdd(opts.add) @@ -76,29 +115,9 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string return } - const styleDefs: [string, string, string][] = [ - ["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"], - ["saved", green, "●"], ["review", magenta, "⊛"], - ] - const styles = Object.fromEntries( - styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }]) - ) - const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length)) - const cols = process.stdout.columns || 80 - const prefixWidth = branchWidth + 4 - - console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) - - for (const s of sessions) { - const prompt = (s.prompt ?? "").split("\n")[0] - const status = statuses[s.branch] ?? "idle" - const { icon, color: bc } = styles[status] - const maxPrompt = cols - prefixWidth - const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt - console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) - } - - console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`) + const displaySessions = sessions.map(s => ({ ...s, key: s.branch })) + renderSessions(displaySessions, statuses) + renderLegend() if ((await vm.status()) !== "running") { console.log(`\n${red}VM is not running.${reset}`) @@ -122,7 +141,7 @@ async function actionRemove(dir: string) { if (removed) { console.log(`${red}-${reset} ${dir}`) } else { - console.log(`Project not found in registry: ${dir}`) + die(`Project not found in registry: ${dir}`) } } @@ -139,29 +158,45 @@ async function actionAll(opts: { json?: boolean }) { return } - // Determine status for each session in parallel + // Determine status for each session in parallel, keyed by repo+branch const vmRunning = (await vm.status()) === "running" + const staleReviews: { repoRoot: string; branch: string }[] = [] const statusEntries = await Promise.all( sessions.map(async (s): Promise<[string, string]> => { - if (!vmRunning) return [s.branch, "idle"] + const key = `${s.repo}/${s.branch}` + if (!vmRunning) return [key, "idle"] const active = await vm.isClaudeActive(s.worktree, s.branch) - if (active && s.in_review) return [s.branch, "review"] - if (active) return [s.branch, "active"] + if (active && s.in_review) return [key, "review"] + if (!active && s.in_review) { + staleReviews.push({ repoRoot: s.repoRoot, branch: s.branch }) + } + if (active) return [key, "active"] const dirty = await git.isDirty(s.worktree) - if (dirty) return [s.branch, "dirty"] + if (dirty) return [key, "dirty"] const commits = await git.hasNewCommits(s.worktree) - return [s.branch, commits ? "saved" : "idle"] + return [key, commits ? "saved" : "idle"] }) ) const statuses = Object.fromEntries(statusEntries) - const styleDefs: [string, string, string][] = [ - ["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"], - ["saved", green, "●"], ["review", magenta, "⊛"], - ] - const styles = Object.fromEntries( - styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }]) - ) + // Clear stale in_review flags grouped by repo + if (staleReviews.length > 0) { + const byRepo = new Map() + for (const { repoRoot, branch } of staleReviews) { + const list = byRepo.get(repoRoot) ?? [] + list.push(branch) + byRepo.set(repoRoot, list) + } + for (const [repoRoot, branches] of byRepo) { + try { + const fresh = await state.load(repoRoot) + for (const branch of branches) { + if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false + } + await state.save(repoRoot, fresh) + } catch {} + } + } // Group by repo const byRepo = new Map() @@ -171,25 +206,13 @@ async function actionAll(opts: { json?: boolean }) { byRepo.set(s.repo, list) } - const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length)) - const cols = process.stdout.columns || 80 - const prefixWidth = branchWidth + 4 - for (const [repo, repoSessions] of byRepo) { console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`) - console.log(` ${dim}${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`) - - for (const s of repoSessions) { - const prompt = (s.prompt ?? "").split("\n")[0] - const status = statuses[s.branch] ?? "idle" - const { icon, color: bc } = styles[status] - const maxPrompt = cols - prefixWidth - const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt - console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) - } + const displaySessions = repoSessions.map(s => ({ ...s, key: `${s.repo}/${s.branch}` })) + renderSessions(displaySessions, statuses) } - console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`) + renderLegend() if (!vmRunning) { console.log(`\n${red}VM is not running.${reset}`) diff --git a/src/state.ts b/src/state.ts index 6cc0b33..d8aa03b 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,5 +1,5 @@ -import { join, basename, dirname, resolve } from "path" -import { readdir, rename } from "fs/promises" +import { join, basename, resolve } from "path" +import { readdir, rename, mkdir } from "fs/promises" import { homedir } from "os" export interface Session { @@ -34,7 +34,6 @@ export async function save(repoRoot: string, state: State): Promise { await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists await Bun.write(tmpPath, JSON.stringify(state, null, 2) + "\n") await rename(tmpPath, path) - await registerProject(repoRoot).catch(() => {}) } export async function getSession(repoRoot: string, branch: string): Promise { @@ -46,6 +45,7 @@ export async function setSession(repoRoot: string, session: Session): Promise {}) } export async function removeSession(repoRoot: string, branch: string): Promise { @@ -54,43 +54,54 @@ export async function removeSession(repoRoot: string, branch: string): Promise { const file = Bun.file(globalStatePath()) if (await file.exists()) { - return await file.json() + try { + const data = await file.json() + if (data && Array.isArray(data.projects)) return data + } catch {} } return { projects: [] } } async function saveGlobal(gs: GlobalState): Promise { + await mkdir(GLOBAL_DIR, { recursive: true }) const path = globalStatePath() const tmpPath = path + ".tmp" await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n") await rename(tmpPath, path) } +function normalizePath(dir: string): string { + return resolve(dir.replace(/^~/, homedir())) +} + /** Register a project directory in the global state. */ export async function registerProject(repoRoot: string): Promise { + const normalized = normalizePath(repoRoot) const gs = await loadGlobal() - if (!gs.projects.includes(repoRoot)) { - gs.projects.push(repoRoot) + if (!gs.projects.includes(normalized)) { + gs.projects.push(normalized) await saveGlobal(gs) } } /** Remove a project directory from the global state. */ export async function unregisterProject(dir: string): Promise { - const target = resolve(dir.replace(/^~/, homedir())) + const target = normalizePath(dir) const gs = await loadGlobal() const idx = gs.projects.indexOf(target) if (idx === -1) return false @@ -101,8 +112,8 @@ export async function unregisterProject(dir: string): Promise { /** Recursively scan a directory for .sandlot dirs and register their parent projects. */ export async function scanAndRegister(dir: string): Promise { - const root = resolve(dir.replace(/^~/, homedir())) - const registered: string[] = [] + const root = normalizePath(dir) + const found: string[] = [] async function walk(d: string) { let entries @@ -114,26 +125,37 @@ export async function scanAndRegister(dir: string): Promise { const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot") if (hasSandlot) { - // Verify it has a state.json const stateFile = Bun.file(join(d, ".sandlot", "state.json")) if (await stateFile.exists()) { - await registerProject(d) - registered.push(d) + found.push(normalizePath(d)) } } - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue - await walk(join(d, entry.name)) - } + const children = entries.filter(e => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules") + await Promise.all(children.map(entry => walk(join(d, entry.name)))) } await walk(root) - return registered + + // Bulk register all found projects in a single load/save cycle + if (found.length > 0) { + const gs = await loadGlobal() + let changed = false + for (const p of found) { + if (!gs.projects.includes(p)) { + gs.projects.push(p) + changed = true + } + } + if (changed) await saveGlobal(gs) + } + + return found } export interface GlobalSession extends Session { repo: string + repoRoot: string } /** Load all sessions across all registered projects. */ @@ -146,7 +168,7 @@ export async function loadAll(): Promise { const st = await load(project) const repo = basename(project) for (const session of Object.values(st.sessions)) { - all.push({ ...session, repo }) + all.push({ ...session, repo, repoRoot: project }) } } catch {} }