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:
@@ -384,3 +384,57 @@ describe('loadConfig — returned object is frozen', () => {
|
||||
expect(Object.isFrozen(cfg.allowedOrigins)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── v0.6 project discovery config ───────────────────────────────────────────────
|
||||
describe('loadConfig — project discovery (v0.6)', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('defaults: projectRoots=[homeDir], depth 4, ttl 10000, dirtyCheck true', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.projectRoots).toEqual(['/home/testuser'])
|
||||
expect(cfg.projectScanDepth).toBe(4)
|
||||
expect(cfg.projectScanTtlMs).toBe(10_000)
|
||||
expect(cfg.projectDirtyCheck).toBe(true)
|
||||
expect(Object.isFrozen(cfg.projectRoots)).toBe(true)
|
||||
})
|
||||
|
||||
it('PROJECT_ROOTS: comma-separated absolute paths, trimmed', () => {
|
||||
const cfg = loadConfig({ PROJECT_ROOTS: '/a, /b/c ,/d' })
|
||||
expect(cfg.projectRoots).toEqual(['/a', '/b/c', '/d'])
|
||||
})
|
||||
|
||||
it('PROJECT_ROOTS: expands ~ and ~/sub to homeDir', () => {
|
||||
const cfg = loadConfig({ PROJECT_ROOTS: '~,~/code' })
|
||||
expect(cfg.projectRoots).toEqual(['/home/testuser', '/home/testuser/code'])
|
||||
})
|
||||
|
||||
it('PROJECT_ROOTS: empty / whitespace falls back to [homeDir]', () => {
|
||||
expect(loadConfig({ PROJECT_ROOTS: '' }).projectRoots).toEqual(['/home/testuser'])
|
||||
expect(loadConfig({ PROJECT_ROOTS: ' ' }).projectRoots).toEqual(['/home/testuser'])
|
||||
})
|
||||
|
||||
it('PROJECT_ROOTS: a relative path fails fast with a clear error', () => {
|
||||
expect(() => loadConfig({ PROJECT_ROOTS: 'relative/dir' })).toThrow(/absolute path/)
|
||||
expect(() => loadConfig({ PROJECT_ROOTS: '/ok,./bad' })).toThrow(/PROJECT_ROOTS/)
|
||||
})
|
||||
|
||||
it('PROJECT_SCAN_DEPTH / PROJECT_SCAN_TTL override and validate', () => {
|
||||
const cfg = loadConfig({ PROJECT_SCAN_DEPTH: '2', PROJECT_SCAN_TTL: '0' })
|
||||
expect(cfg.projectScanDepth).toBe(2)
|
||||
expect(cfg.projectScanTtlMs).toBe(0)
|
||||
expect(() => loadConfig({ PROJECT_SCAN_DEPTH: 'deep' })).toThrow(/PROJECT_SCAN_DEPTH/)
|
||||
expect(() => loadConfig({ PROJECT_SCAN_TTL: '-1' })).toThrow(/PROJECT_SCAN_TTL/)
|
||||
})
|
||||
|
||||
it('PROJECT_DIRTY_CHECK: parses on/off forms, defaults true', () => {
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: '0' }).projectDirtyCheck).toBe(false)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'off' }).projectDirtyCheck).toBe(false)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'false' }).projectDirtyCheck).toBe(false)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'on' }).projectDirtyCheck).toBe(true)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: '1' }).projectDirtyCheck).toBe(true)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'garbage' }).projectDirtyCheck).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user