diff --git a/src/cli.ts b/src/cli.ts index 39dcc36..c5f081a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -52,6 +52,9 @@ program .command("list") .description("Show all active sessions") .option("--json", "Output as JSON") + .option("-a, --all", "Show sessions across all projects") + .option("--add ", "Scan a directory for sandlot projects and register them") + .option("-r, --remove ", "Remove a project from the registry") .action(listAction) program diff --git a/src/commands/list.ts b/src/commands/list.ts index b21e208..30ee151 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -4,7 +4,11 @@ import * as vm from "../vm.ts" import * as state from "../state.ts" import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts" -export async function action(opts: { json?: boolean }) { +export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) { + if (opts.add) return actionAdd(opts.add) + if (opts.remove) return actionRemove(opts.remove) + if (opts.all) return actionAll(opts) + const root = await git.repoRoot() const st = await state.load(root) const sessions = Object.values(st.sessions) @@ -100,3 +104,94 @@ export async function action(opts: { json?: boolean }) { console.log(`\n${red}VM is not running.${reset}`) } } + +async function actionAdd(dir: string) { + const registered = await state.scanAndRegister(dir) + if (registered.length === 0) { + console.log(`No sandlot projects found under ${dir}`) + } else { + for (const p of registered) { + console.log(`${green}+${reset} ${p}`) + } + console.log(`\nRegistered ${registered.length} project${registered.length === 1 ? "" : "s"}.`) + } +} + +async function actionRemove(dir: string) { + const removed = await state.unregisterProject(dir) + if (removed) { + console.log(`${red}-${reset} ${dir}`) + } else { + console.log(`Project not found in registry: ${dir}`) + } +} + +async function actionAll(opts: { json?: boolean }) { + const sessions = await state.loadAll() + + if (opts.json) { + console.log(JSON.stringify(sessions, null, 2)) + return + } + + if (sessions.length === 0) { + console.log("◆ No active sessions across any project.") + return + } + + // Determine status for each session in parallel + const vmRunning = (await vm.status()) === "running" + const statusEntries = await Promise.all( + sessions.map(async (s): Promise<[string, string]> => { + if (!vmRunning) return [s.branch, "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"] + const dirty = await git.isDirty(s.worktree) + if (dirty) return [s.branch, "dirty"] + const commits = await git.hasNewCommits(s.worktree) + return [s.branch, 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 }]) + ) + + // Group by repo + const byRepo = new Map() + for (const s of sessions) { + const list = byRepo.get(s.repo) ?? [] + list.push(s) + 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}`) + } + } + + console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`) + + if (!vmRunning) { + console.log(`\n${red}VM is not running.${reset}`) + } +} diff --git a/src/state.ts b/src/state.ts index f212ec5..6cc0b33 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,4 +1,4 @@ -import { join, dirname } from "path" +import { join, basename, dirname, resolve } from "path" import { readdir, rename } from "fs/promises" import { homedir } from "os" @@ -34,6 +34,7 @@ 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 { @@ -53,50 +54,101 @@ export async function removeSession(repoRoot: string, branch: string): Promise { + const file = Bun.file(globalStatePath()) + if (await file.exists()) { + return await file.json() + } + return { projects: [] } +} + +async function saveGlobal(gs: GlobalState): Promise { + const path = globalStatePath() + const tmpPath = path + ".tmp" + await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n") + await rename(tmpPath, path) +} + +/** Register a project directory in the global state. */ +export async function registerProject(repoRoot: string): Promise { + const gs = await loadGlobal() + if (!gs.projects.includes(repoRoot)) { + gs.projects.push(repoRoot) + 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 gs = await loadGlobal() + const idx = gs.projects.indexOf(target) + if (idx === -1) return false + gs.projects.splice(idx, 1) + await saveGlobal(gs) + return true +} + +/** 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[] = [] + + async function walk(d: string) { + let entries + try { + entries = await readdir(d, { withFileTypes: true }) + } catch { + return + } + + 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) + } + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue + await walk(join(d, entry.name)) + } + } + + await walk(root) + return registered +} + export interface GlobalSession extends Session { repo: string } -/** Discover all sessions across all repos by scanning ~/.sandlot/ */ +/** Load all sessions across all registered projects. */ export async function loadAll(): Promise { - const sandlotDir = join(homedir(), ".sandlot") + const gs = await loadGlobal() const all: GlobalSession[] = [] - let repoDirs - try { - repoDirs = await readdir(sandlotDir, { withFileTypes: true }) - } catch { - return [] - } - - for (const entry of repoDirs) { - if (!entry.isDirectory() || entry.name.startsWith(".")) continue - const repoDir = join(sandlotDir, entry.name) - - // Find the main repo root from a worktree's .git pointer - let repoRoot: string | null = null - const branchEntries = await readdir(repoDir, { withFileTypes: true }).catch(() => []) - - for (const be of branchEntries) { - if (!be.isDirectory() || be.name.startsWith(".")) continue - const dotGit = await Bun.file(join(repoDir, be.name, ".git")).text().catch(() => "") - const m = dotGit.match(/^gitdir:\s*(.+)/m) - if (m) { - // gitdir: /path/to/repo/.git/worktrees/ - const mainGit = m[1].trim().replace(/\/worktrees\/[^/]+$/, "") - repoRoot = dirname(mainGit) - break + for (const project of gs.projects) { + try { + const st = await load(project) + const repo = basename(project) + for (const session of Object.values(st.sessions)) { + all.push({ ...session, repo }) } - } - - if (repoRoot) { - try { - const st = await load(repoRoot) - for (const session of Object.values(st.sessions)) { - all.push({ ...session, repo: entry.name }) - } - } catch {} - } + } catch {} } return all