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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user