feat(v0.6): per-project detail view — branch/worktrees + active sessions
Clicking a project's name opens an in-app detail view (back button returns to the grid) with: - header: name, path, branch, dirty - Branch / Worktrees: each `git worktree list` entry (branch or detached@head, main/current/locked/prunable tags, path) — a single-worktree repo shows its branch; a multi-worktree repo shows them all - Active sessions (N): detailed rows (status, devices watching, started-ago) with Open + Kill - the Claude/Codex/VS Code launcher row Backend: src/http/worktrees.ts (parseWorktrees pure + listWorktrees via execFile, no shell, best-effort); buildProjectDetail(cfg, path, liveSessions) in projects.ts (validates absolute existing dir, reuses branch/dirty/session helpers, uncached — detail is on-demand and wants fresh state); read-only GET /projects/detail?path= (no Origin guard, like /projects; 400 missing / 404 invalid). Types: WorktreeInfo, ProjectDetail. Frontend: detail view + navigation in projects.ts (grid<->detail), project name becomes a link, Kill via DELETE /live-sessions/:id. Tests +21 (452 total): worktree parser, buildProjectDetail (validation, non-git, session merge, real git-init worktree), renderProjectDetail. worktrees.ts 100% / projects.ts 93.8% cov. Verified in-browser: opened a Claude session, then the detail showed its branch+worktree and the live session with Open/Kill.
This commit is contained in:
@@ -14,7 +14,9 @@ vi.mock('../src/http/history.js', () => ({
|
||||
listSessions: (...a: unknown[]) => mockListSessions(...a),
|
||||
}))
|
||||
|
||||
const { parseGitHead, buildProjects, _clearProjectCache } = await import('../src/http/projects.js')
|
||||
const { parseGitHead, buildProjects, buildProjectDetail, _clearProjectCache } = await import(
|
||||
'../src/http/projects.js'
|
||||
)
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
|
||||
@@ -347,3 +349,73 @@ describe('buildProjects — dirty check', () => {
|
||||
expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildProjectDetail ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildProjectDetail', () => {
|
||||
it('returns null for a relative path', async () => {
|
||||
expect(await buildProjectDetail(makeCfg({}), 'relative/dir', [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a non-existent path', async () => {
|
||||
expect(await buildProjectDetail(makeCfg({}), path.join(tmp, 'nope'), [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the path is a file', async () => {
|
||||
const f = path.join(tmp, 'file.txt')
|
||||
await fs.writeFile(f, 'x')
|
||||
expect(await buildProjectDetail(makeCfg({}), f, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a non-git directory with no worktrees', async () => {
|
||||
const dir = path.join(tmp, 'plain')
|
||||
await fs.mkdir(dir)
|
||||
const d = await buildProjectDetail(makeCfg({}), dir, [])
|
||||
expect(d).not.toBeNull()
|
||||
expect(d!.isGit).toBe(false)
|
||||
expect(d!.worktrees).toEqual([])
|
||||
expect(d!.name).toBe('plain')
|
||||
})
|
||||
|
||||
it('reads the branch for a git repo (worktrees best-effort)', async () => {
|
||||
const dir = path.join(tmp, 'repo')
|
||||
await makeRepo(dir, 'develop')
|
||||
const d = await buildProjectDetail(makeCfg({}), dir, [])
|
||||
expect(d!.isGit).toBe(true)
|
||||
expect(d!.branch).toBe('develop')
|
||||
expect(Array.isArray(d!.worktrees)).toBe(true) // real `git` fails on the fake .git → []
|
||||
})
|
||||
|
||||
it('merges running sessions whose cwd is the project or under it', async () => {
|
||||
const dir = path.join(tmp, 'repo')
|
||||
await fs.mkdir(dir)
|
||||
const live = [
|
||||
makeLive({ id: 'a', cwd: dir, status: 'working' }),
|
||||
makeLive({ id: 'b', cwd: path.join(dir, 'sub') }),
|
||||
makeLive({ id: 'c', cwd: path.join(tmp, 'other') }),
|
||||
]
|
||||
const d = await buildProjectDetail(makeCfg({}), dir, live)
|
||||
expect(d!.sessions.map((s) => s.id).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('lists worktrees for a real git repo (gated on git)', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
await fs.mkdir(path.join(tmp, 'realrepo'))
|
||||
// git canonicalises paths (/var → /private/var on macOS), so pass the
|
||||
// realpath — real project paths from discovery are already canonical.
|
||||
const dir = await fs.realpath(path.join(tmp, 'realrepo'))
|
||||
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir })
|
||||
await execFileP('git', ['config', 'user.email', 't@t'], { cwd: dir })
|
||||
await execFileP('git', ['config', 'user.name', 't'], { cwd: dir })
|
||||
await fs.writeFile(path.join(dir, 'f.txt'), 'hi')
|
||||
await execFileP('git', ['add', '.'], { cwd: dir })
|
||||
await execFileP('git', ['commit', '-q', '-m', 'init'], { cwd: dir })
|
||||
|
||||
const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), dir, [])
|
||||
expect(d!.isGit).toBe(true)
|
||||
expect(d!.branch).toBe('main')
|
||||
expect(d!.worktrees.length).toBeGreaterThanOrEqual(1)
|
||||
expect(d!.worktrees[0]).toMatchObject({ isMain: true, isCurrent: true, branch: 'main' })
|
||||
expect(d!.dirty).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user