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:
Yaojia Wang
2026-06-30 13:29:41 +02:00
parent a390130205
commit 99bc6fd9f6
10 changed files with 810 additions and 12 deletions

View File

@@ -21,8 +21,16 @@ class FakeTerminal {
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
// Dynamic import AFTER mock declaration so the factory is in place.
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject, makeProjectCard } =
await import('../public/projects.js')
const {
filterProjects,
sortProjects,
getFavs,
saveFavs,
toggleFav,
normalizeProject,
makeProjectCard,
renderProjectDetail,
} = await import('../public/projects.js')
/* ── Helpers ───────────────────────────────────────────────────────────────── */
@@ -352,4 +360,109 @@ describe('makeProjectCard — launcher row', () => {
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude .proj-launch-badge')?.textContent).toBe('2')
})
it('makes the name a detail link only when onOpenDetail is given', () => {
const onOpenDetail = vi.fn()
const card = makeProjectCard(makeProject({ path: '/p/r' }), new Set(), noopHooks(), () => {}, onOpenDetail)
const name = card.querySelector('.proj-name') as HTMLElement
expect(name.classList.contains('proj-name-link')).toBe(true)
name.click()
expect(onOpenDetail).toHaveBeenCalledWith('/p/r')
const plain = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
expect(plain.querySelector('.proj-name')!.classList.contains('proj-name-link')).toBe(false)
})
})
/* ── renderProjectDetail (branch/worktrees + active sessions) ───────────────── */
describe('renderProjectDetail', () => {
const hooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
const cbs = () => ({ onBack: vi.fn(), onKill: vi.fn() })
function detail(over: Partial<import('../src/types.js').ProjectDetail> = {}) {
return {
name: 'repo',
path: '/p/repo',
isGit: true,
branch: 'main',
worktrees: [],
sessions: [],
...over,
}
}
it('renders "Project not found" + working Back for a null detail', () => {
const cb = cbs()
const root = renderProjectDetail(null, hooks(), cb)
expect(root.textContent).toContain('Project not found')
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
expect(cb.onBack).toHaveBeenCalled()
})
it('shows the header name, path, and branch', () => {
const root = renderProjectDetail(detail(), hooks(), cbs())
expect(root.querySelector('.proj-detail-name')?.textContent).toBe('repo')
expect(root.querySelector('.proj-detail-path')?.textContent).toBe('/p/repo')
expect(root.querySelector('.proj-branch')?.textContent).toBe('main')
})
it('lists worktrees with branch, tags, and path', () => {
const root = renderProjectDetail(
detail({
worktrees: [
{ path: '/p/repo', branch: 'main', head: 'abc12345', isMain: true, isCurrent: true },
{ path: '/p/wt/feat', branch: 'feature/x', head: 'def67890', isMain: false, isCurrent: false },
],
}),
hooks(),
cbs(),
)
const rows = root.querySelectorAll('.proj-wt-row')
expect(rows).toHaveLength(2)
expect(rows[0]?.querySelector('.proj-wt-branch')?.textContent).toBe('main')
expect(rows[0]?.querySelector('.proj-wt-tag-current')).not.toBeNull()
expect(rows[1]?.querySelector('.proj-wt-branch')?.textContent).toBe('feature/x')
})
it('renders detached worktrees as "detached @ head"', () => {
const root = renderProjectDetail(
detail({ worktrees: [{ path: '/p/repo', head: 'deadbeef', isMain: true, isCurrent: true }] }),
hooks(),
cbs(),
)
expect(root.querySelector('.proj-wt-branch')?.textContent).toBe('detached @ deadbeef')
})
it('lists active sessions with Open + Kill wired, ignoring exited ones', () => {
const h = hooks()
const cb = cbs()
const root = renderProjectDetail(
detail({
sessions: [
{ id: 's1', title: 'claude', status: 'working', clientCount: 2, createdAt: 1, exited: false },
{ id: 'gone', status: 'idle', clientCount: 0, createdAt: 1, exited: true },
],
}),
h,
cb,
)
const rows = root.querySelectorAll('.proj-dsession')
expect(rows).toHaveLength(1)
;(rows[0]!.querySelector('.proj-dsession-open') as HTMLButtonElement).click()
expect(h.onEnterSession).toHaveBeenCalledWith('s1')
;(rows[0]!.querySelector('.proj-dsession-kill') as HTMLButtonElement).click()
expect(cb.onKill).toHaveBeenCalledWith('s1')
})
it('shows an empty-state when no sessions are running, plus the launcher row', () => {
const root = renderProjectDetail(detail(), hooks(), cbs())
expect(root.textContent).toContain('No running sessions')
expect(root.querySelectorAll('.proj-launch')).toHaveLength(3)
})
it('flags a non-git directory', () => {
const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs())
expect(root.textContent).toContain('Not a git repository')
})
})

View File

@@ -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)
})
})

83
test/worktrees.test.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* test/worktrees.test.ts — parseWorktrees (pure parser for `git worktree list
* --porcelain`). The git wrapper itself is covered via the projects-detail
* integration test in projects.test.ts.
*/
import { describe, it, expect } from 'vitest'
import { parseWorktrees } from '../src/http/worktrees.js'
const MAIN = [
'worktree /repo/main',
'HEAD 1234567890abcdef1234567890abcdef12345678',
'branch refs/heads/main',
'',
].join('\n')
describe('parseWorktrees', () => {
it('parses a single main worktree on a branch', () => {
const wts = parseWorktrees(MAIN, '/repo/main')
expect(wts).toHaveLength(1)
expect(wts[0]).toMatchObject({
path: '/repo/main',
branch: 'main',
head: '12345678', // first 8 of the sha
isMain: true,
isCurrent: true,
})
})
it('marks only the first record as main; flags the current path', () => {
const text = [
'worktree /repo/main',
'HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'branch refs/heads/main',
'',
'worktree /repo/wt/feature',
'HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'branch refs/heads/feature/login',
'',
].join('\n')
const wts = parseWorktrees(text, '/repo/wt/feature')
expect(wts).toHaveLength(2)
expect(wts[0]).toMatchObject({ path: '/repo/main', branch: 'main', isMain: true, isCurrent: false })
expect(wts[1]).toMatchObject({
path: '/repo/wt/feature',
branch: 'feature/login', // refs/heads/ stripped, slashes kept
isMain: false,
isCurrent: true,
})
})
it('handles a detached HEAD (no branch)', () => {
const text = ['worktree /repo/x', 'HEAD cccccccccccccccccccccccccccccccccccccccc', 'detached', ''].join('\n')
const wts = parseWorktrees(text, '/other')
expect(wts[0]?.branch).toBeUndefined()
expect(wts[0]?.head).toBe('cccccccc')
})
it('captures locked and prunable flags', () => {
const text = [
'worktree /repo/locked',
'HEAD dddddddddddddddddddddddddddddddddddddddd',
'branch refs/heads/wip',
'locked',
'prunable gitdir file points to non-existent location',
'',
].join('\n')
const wts = parseWorktrees(text, '/x')
expect(wts[0]?.locked).toBe(true)
expect(wts[0]?.prunable).toBe(true)
})
it('parses the final record even without a trailing blank line', () => {
const text = ['worktree /repo/main', 'HEAD eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 'branch refs/heads/main'].join(
'\n',
)
expect(parseWorktrees(text, '/repo/main')).toHaveLength(1)
})
it('returns [] for empty input', () => {
expect(parseWorktrees('', '/x')).toEqual([])
})
})