Replace filesystem-scanning session discovery with an explicit project registry
The old loadAll() walked ~/.sandlot/ and reverse-engineered repo roots from .git worktree pointers, which was fragile and slow. A simple registry (~/.sandlot/state.json) tracks known projects explicitly, with commands to add, remove, and list across all of them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f504584ce4
commit
5893e07530
|
|
@ -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 <dir>", "Scan a directory for sandlot projects and register them")
|
||||
.option("-r, --remove <dir>", "Remove a project from the registry")
|
||||
.action(listAction)
|
||||
|
||||
program
|
||||
|
|
|
|||
|
|
@ -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<string, typeof sessions>()
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
126
src/state.ts
126
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<void> {
|
|||
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<Session | undefined> {
|
||||
|
|
@ -53,50 +54,101 @@ export async function removeSession(repoRoot: string, branch: string): Promise<v
|
|||
await save(repoRoot, state)
|
||||
}
|
||||
|
||||
// ── Global state: ~/.sandlot/state.json ──────────────────────────────
|
||||
|
||||
interface GlobalState {
|
||||
projects: string[] // repo root paths
|
||||
}
|
||||
|
||||
function globalStatePath(): string {
|
||||
return join(homedir(), ".sandlot", "state.json")
|
||||
}
|
||||
|
||||
async function loadGlobal(): Promise<GlobalState> {
|
||||
const file = Bun.file(globalStatePath())
|
||||
if (await file.exists()) {
|
||||
return await file.json()
|
||||
}
|
||||
return { projects: [] }
|
||||
}
|
||||
|
||||
async function saveGlobal(gs: GlobalState): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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<GlobalSession[]> {
|
||||
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/<name>
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user