feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring

Home screen gains a Sessions↔Projects segmented control. The Projects view
discovers the host's git repos and, on click, opens a tab named after the
repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project).

Backend (P2/P4):
- src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions):
  depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops
  descending at a repo), .git/HEAD branch parse, rate-limited `git status`
  dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of
  ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session
  merge (fresh each call), TTL discovery cache with in-flight dedup.
- src/server.ts — read-only GET /projects (no Origin guard, []-fallback).

Frontend (P5/P6):
- public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot,
  session rows, "+ start claude here"), live search, localStorage favourites;
  pure helpers extracted for unit tests.
- public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'),
  countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView).
- public/style.css — Projects panel + .home-seg control styles.

Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already
added; this commit adds fail-fast rejection of relative PROJECT_ROOTS.

Review hardening (4 confirmed HIGH + boundary validation):
- carriage-return fix so claude auto-executes (A3); seg-control z-order;
  parallelised history merge; concurrent-cache-miss dedup.
- normalizeProject guards the /projects response; getFavs filters non-strings.

Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%.
Verified: both typechecks clean, full suite green, build:web OK, coverage
≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
This commit is contained in:
Yaojia Wang
2026-06-30 11:04:49 +02:00
parent dc5d073374
commit 7b4adf5072
12 changed files with 2060 additions and 35 deletions

View File

@@ -2,10 +2,10 @@
/**
* test/tabs.test.ts — frontend TabApp unit tests.
*
* Mocks TerminalSession + the launcher so we test TabApp's tab lifecycle in
* isolation: the v0.5 "close last tab → launcher" invariant and that addEntry
* builds a real (non-null) session before pushing the entry (Phase 1#5 — the
* `null as unknown as TerminalSession` hole is gone).
* Mocks TerminalSession + the launcher + projects panel so we test TabApp's
* tab lifecycle in isolation: the v0.5 "close last tab → launcher" invariant,
* that addEntry builds a real (non-null) session before pushing the entry
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -19,6 +19,8 @@ class FakeTerminalSession {
claudeStatus = 'unknown'
cwd: string | null = null
pendingApproval = false
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
initialInput: string | undefined = undefined
connect = vi.fn()
dispose = vi.fn()
show = vi.fn()
@@ -41,13 +43,16 @@ class FakeTerminalSession {
static instances: FakeTerminalSession[] = []
constructor(opts: {
sessionId: string | null
initialInput?: string
onClaudeStatus?: (s: string, d?: string) => void
onStatus?: (s: string) => void
onActivity?: () => void
onTitle?: (t: string) => void
[key: string]: unknown
}) {
constructed += 1
this.id = opts.sessionId
this.initialInput = opts.initialInput
this.el = document.createElement('div')
this.cbs = opts
FakeTerminalSession.instances.push(this)
@@ -66,6 +71,16 @@ const mountLauncher = vi.fn(() => ({
}))
vi.mock('../public/launcher.js', () => ({ mountLauncher }))
// ── Mock the projects panel (records visibility) ──────────────────────────────
const projectsVisible = { value: false }
const mountProjects = vi.fn(() => ({
setVisible: (v: boolean) => {
projectsVisible.value = v
},
refresh: vi.fn(),
}))
vi.mock('../public/projects.js', () => ({ mountProjects }))
// settings.js is light but imports nothing heavy; let it load for real.
const { TabApp } = await import('../public/tabs.js')
@@ -81,6 +96,7 @@ beforeEach(() => {
constructed = 0
FakeTerminalSession.instances = []
launcherVisible.value = false
projectsVisible.value = false
document.body.replaceChildren()
localStorage.clear()
})
@@ -201,6 +217,22 @@ describe('TabApp — multi-tab lifecycle', () => {
expect(app.snapshot()).toHaveLength(1)
})
it('newTabForResume initialInput ends with \\r so the shell auto-executes it (Finding 1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
const session = FakeTerminalSession.instances[0]!
expect(session.initialInput).toBe('claude --resume 33333333-3333-4333-8333-333333333333\r')
})
it('openProject default initialInput is "claude\\r" so the shell auto-executes it (A3/Finding 1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
const session = FakeTerminalSession.instances[0]!
expect(session.initialInput).toBe('claude\r')
})
it('snapshot reflects per-tab connection + claude status fields', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
@@ -346,3 +378,88 @@ describe('TabApp — DOM interactions on the tab bar', () => {
expect(app.snapshot()).toHaveLength(2)
})
})
describe('TabApp — P6 openProject + home segmented control', () => {
it('openProject creates a tab with the repo name as its displayed title', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
expect(app.snapshot()).toHaveLength(1)
expect(app.snapshot()[0]!.title).toBe('myrepo')
})
it('a second openProject with the same name gets a #2 suffix', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
app.openProject('/path/to/myrepo', 'myrepo')
const snap = app.snapshot()
expect(snap).toHaveLength(2)
expect(snap[0]!.title).toBe('myrepo')
expect(snap[1]!.title).toBe('myrepo #2')
})
it('countOpenWithTitlePrefix increments correctly for three tabs with the same base name', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p', 'proj')
app.openProject('/p', 'proj')
app.openProject('/p', 'proj')
const snap = app.snapshot()
expect(snap[0]!.title).toBe('proj')
expect(snap[1]!.title).toBe('proj #2')
expect(snap[2]!.title).toBe('proj #3')
})
it('openProject hides the segmented control and both panels (tab open)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p/repo', 'repo')
const seg = paneHost.querySelector('.home-seg') as HTMLElement
expect(seg).not.toBeNull()
expect(seg.style.display).toBe('none')
expect(launcherVisible.value).toBe(false)
expect(projectsVisible.value).toBe(false)
})
it('home segmented control shows both panels depending on selected view', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
// Initially: sessions view → launcher visible, projects hidden.
expect(launcherVisible.value).toBe(true)
expect(projectsVisible.value).toBe(false)
// Click the "Projects" button (second .home-seg-btn).
const projBtn = paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement
projBtn.click()
expect(launcherVisible.value).toBe(false)
expect(projectsVisible.value).toBe(true)
// Click the "Sessions" button (first .home-seg-btn).
const sessBtn = paneHost.querySelectorAll('.home-seg-btn')[0] as HTMLButtonElement
sessBtn.click()
expect(launcherVisible.value).toBe(true)
expect(projectsVisible.value).toBe(false)
})
it('closing the last tab shows the segmented control and restores home view', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const seg = paneHost.querySelector('.home-seg') as HTMLElement
expect(seg.style.display).toBe('none') // hidden while tab open
app.closeTab(0)
expect(seg.style.display).not.toBe('none') // back to home
expect(launcherVisible.value).toBe(true) // sessions view by default
})
it('openProject with an empty repoPath still opens a tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
expect(() => app.openProject('', 'bare-repo')).not.toThrow()
expect(app.snapshot()).toHaveLength(1)
expect(app.snapshot()[0]!.title).toBe('bare-repo')
})
})