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:
355
src/http/projects.ts
Normal file
355
src/http/projects.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* src/http/projects.ts (v0.6 Project Manager — P2) — discover host projects.
|
||||
*
|
||||
* Modelled on history.ts: fully async, best-effort, never throws to the caller.
|
||||
* Steps (see docs/FEATURE_PROJECT_MANAGER.md §4.3):
|
||||
* 1. breadth-first scan cfg.projectRoots for git repos (depth-capped, skipping
|
||||
* node_modules / dotdirs / symlinks — bounds the DoS/scan surface).
|
||||
* 2. per repo: branch from .git/HEAD (cheap, no spawn); optional dirty via
|
||||
* `git status --porcelain` (execFile, no shell, timeout + concurrency cap).
|
||||
* 3. merge recently-used cwds from ~/.claude/projects (history.ts).
|
||||
* 4. merge live sessions by cwd → ProjectInfo.sessions[] (FRESH every call).
|
||||
*
|
||||
* Steps 1–3 (expensive disk work) are cached at module scope with a TTL; the
|
||||
* step-4 session merge runs fresh on every buildProjects call (sessions churn).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import type { Config, LiveSessionInfo, ProjectInfo, ProjectSessionRef } from '../types.js'
|
||||
import { listSessions } from './history.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// ── constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const GIT_STATUS_TIMEOUT_MS = 2000 // hard kill on a slow `git status`
|
||||
const GIT_CONCURRENCY = 8 // cap simultaneous git/fs work (fork-bomb guard)
|
||||
const GIT_STATUS_MAX_BUFFER = 1024 * 1024 // truncate huge porcelain output
|
||||
const MAX_PROJECTS = 200 // sane cap on the returned list
|
||||
const SKIP_DIR_NAMES = new Set(['node_modules'])
|
||||
|
||||
// ── parse (pure, unit-tested) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a `.git/HEAD` file. `ref: refs/heads/<branch>` → '<branch>' (slashes
|
||||
* kept); a bare 40-hex sha (detached HEAD) or any junk → null.
|
||||
*/
|
||||
export function parseGitHead(headText: string): string | null {
|
||||
const match = /^ref:\s+refs\/heads\/(.+)$/.exec(headText.trim())
|
||||
if (match === null) return null
|
||||
const branch = match[1]?.trim()
|
||||
return branch !== undefined && branch !== '' ? branch : null
|
||||
}
|
||||
|
||||
// ── small async utilities ───────────────────────────────────────────────────────
|
||||
|
||||
/** Run `fn` over `items` with at most `limit` in flight; preserves order. */
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array<R>(items.length)
|
||||
let cursor = 0
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const idx = cursor
|
||||
cursor += 1
|
||||
if (idx >= items.length) return
|
||||
const item = items[idx]
|
||||
if (item === undefined) continue
|
||||
results[idx] = await fn(item)
|
||||
}
|
||||
}
|
||||
const poolSize = Math.min(Math.max(limit, 1), items.length)
|
||||
await Promise.all(Array.from({ length: poolSize }, () => worker()))
|
||||
return results
|
||||
}
|
||||
|
||||
/** Skip dotfiles/dotdirs (incl. .git) and known-heavy dirs when descending. */
|
||||
function shouldSkipDir(name: string): boolean {
|
||||
return name.startsWith('.') || SKIP_DIR_NAMES.has(name)
|
||||
}
|
||||
|
||||
// ── per-repo metadata (best-effort) ─────────────────────────────────────────────
|
||||
|
||||
/** Read the current branch from `<repo>/.git/HEAD`; undefined if unreadable. */
|
||||
async function readBranch(repoPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8')
|
||||
return parseGitHead(head) ?? undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** `git status --porcelain` → dirty?; undefined on error/timeout/non-repo. */
|
||||
async function readDirty(repoPath: string): Promise<boolean | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
cwd: repoPath,
|
||||
timeout: GIT_STATUS_TIMEOUT_MS,
|
||||
maxBuffer: GIT_STATUS_MAX_BUFFER,
|
||||
})
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff `<dir>/.git` exists (file or directory). */
|
||||
async function hasGitEntry(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(path.join(dir, '.git'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface MakeProjectArgs {
|
||||
readonly path: string
|
||||
readonly isGit: boolean
|
||||
readonly branch?: string
|
||||
readonly dirty?: boolean
|
||||
readonly lastActiveMs?: number
|
||||
}
|
||||
|
||||
function makeProject(args: MakeProjectArgs): ProjectInfo {
|
||||
return {
|
||||
name: path.basename(args.path),
|
||||
path: args.path,
|
||||
isGit: args.isGit,
|
||||
branch: args.branch,
|
||||
dirty: args.dirty,
|
||||
lastActiveMs: args.lastActiveMs,
|
||||
sessions: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ── step 1: breadth-first repo scan ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* BFS each root to `maxDepth`. A directory containing a `.git` entry is recorded
|
||||
* as a repo and NOT descended into. Unreadable dirs are skipped (best-effort);
|
||||
* node_modules / dotdirs / symlinks are never followed.
|
||||
*/
|
||||
async function scanRepos(roots: readonly string[], maxDepth: number): Promise<string[]> {
|
||||
const found: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const queue: Array<{ dir: string; depth: number }> = roots.map((r) => ({
|
||||
dir: path.resolve(r),
|
||||
depth: 0,
|
||||
}))
|
||||
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift()
|
||||
if (next === undefined) break
|
||||
const { dir, depth } = next
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue // unreadable dir → skip
|
||||
}
|
||||
|
||||
if (entries.some((e) => e.name === '.git')) {
|
||||
found.push(dir)
|
||||
continue // don't descend into a repo
|
||||
}
|
||||
if (depth >= maxDepth) continue
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) continue // never follow symlinks
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldSkipDir(entry.name)) continue
|
||||
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// ── step 3: merge recently-used cwds from history ───────────────────────────────
|
||||
|
||||
/** Newest mtime per distinct (non-empty) cwd seen in ~/.claude/projects. */
|
||||
async function lastActiveByCwd(): Promise<Map<string, number>> {
|
||||
let sessions
|
||||
try {
|
||||
sessions = await listSessions()
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
const byCwd = new Map<string, number>()
|
||||
for (const s of sessions) {
|
||||
if (s.cwd === '') continue
|
||||
const prev = byCwd.get(s.cwd)
|
||||
if (prev === undefined || s.mtimeMs > prev) byCwd.set(s.cwd, s.mtimeMs)
|
||||
}
|
||||
return byCwd
|
||||
}
|
||||
|
||||
async function mergeHistory(repos: ProjectInfo[]): Promise<ProjectInfo[]> {
|
||||
const byCwd = await lastActiveByCwd()
|
||||
const knownPaths = new Set(repos.map((p) => p.path))
|
||||
|
||||
// Stamp discovered repos that also appear in history.
|
||||
const stamped = repos.map((p) => {
|
||||
const ms = byCwd.get(p.path)
|
||||
return ms === undefined ? p : { ...p, lastActiveMs: ms }
|
||||
})
|
||||
|
||||
// Add history cwds not already in discovered repos — processed concurrently.
|
||||
const historyEntries = [...byCwd.entries()].filter(([cwd]) => !knownPaths.has(cwd))
|
||||
const extras = await mapWithConcurrency(
|
||||
historyEntries,
|
||||
GIT_CONCURRENCY,
|
||||
async ([cwd, ms]) => {
|
||||
const isGit = await hasGitEntry(cwd)
|
||||
const branch = isGit ? await readBranch(cwd) : undefined
|
||||
return makeProject({ path: cwd, isGit, branch, lastActiveMs: ms })
|
||||
},
|
||||
)
|
||||
|
||||
return [...stamped, ...extras]
|
||||
}
|
||||
|
||||
// ── steps 1–3: cached disk discovery ─────────────────────────────────────────────
|
||||
|
||||
interface DiscoverCacheEntry {
|
||||
readonly key: string
|
||||
readonly expiresAt: number
|
||||
readonly projects: ProjectInfo[]
|
||||
}
|
||||
|
||||
let discoverCache: DiscoverCacheEntry | null = null
|
||||
/** Shared in-flight promise so concurrent cache-miss callers join one discovery run. */
|
||||
let inflightDiscovery: { key: string; promise: Promise<ProjectInfo[]> } | null = null
|
||||
|
||||
function cacheKey(cfg: Config): string {
|
||||
return JSON.stringify({
|
||||
roots: cfg.projectRoots,
|
||||
depth: cfg.projectScanDepth,
|
||||
dirty: cfg.projectDirtyCheck,
|
||||
})
|
||||
}
|
||||
|
||||
function dedupByPath(projects: readonly ProjectInfo[]): ProjectInfo[] {
|
||||
const seen = new Set<string>()
|
||||
const out: ProjectInfo[] = []
|
||||
for (const p of projects) {
|
||||
if (seen.has(p.path)) continue
|
||||
seen.add(p.path)
|
||||
out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
|
||||
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
|
||||
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
|
||||
const branch = await readBranch(repoPath)
|
||||
const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined
|
||||
return makeProject({ path: repoPath, isGit: true, branch, dirty })
|
||||
})
|
||||
const merged = await mergeHistory(repos)
|
||||
return dedupByPath(merged)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached steps 1–3, keyed by (roots+depth+dirtyCheck) with cfg.projectScanTtlMs.
|
||||
* Concurrent cache-miss callers share a single in-flight promise (cache-stampede guard).
|
||||
*/
|
||||
async function discoverProjects(cfg: Config): Promise<ProjectInfo[]> {
|
||||
const now = Date.now()
|
||||
const key = cacheKey(cfg)
|
||||
if (discoverCache !== null && discoverCache.key === key && discoverCache.expiresAt > now) {
|
||||
return discoverCache.projects
|
||||
}
|
||||
// Deduplicate concurrent cache-miss calls onto one discovery run.
|
||||
if (inflightDiscovery !== null && inflightDiscovery.key === key) {
|
||||
return inflightDiscovery.promise
|
||||
}
|
||||
const promise = runDiscovery(cfg)
|
||||
.then((projects) => {
|
||||
discoverCache = { key, expiresAt: Date.now() + cfg.projectScanTtlMs, projects }
|
||||
inflightDiscovery = null
|
||||
return projects
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
inflightDiscovery = null
|
||||
throw e
|
||||
})
|
||||
inflightDiscovery = { key, promise }
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Test-only: drop the discovery cache and any in-flight run so each test sees a fresh scan. */
|
||||
export function _clearProjectCache(): void {
|
||||
discoverCache = null
|
||||
inflightDiscovery = null
|
||||
}
|
||||
|
||||
// ── step 4: fresh live-session merge + sort ──────────────────────────────────────
|
||||
|
||||
function lastSegment(cwd: string | null): string | undefined {
|
||||
if (cwd === null || cwd === '') return undefined
|
||||
return cwd.split(path.sep).filter(Boolean).pop()
|
||||
}
|
||||
|
||||
/** A session belongs to a project when its spawn cwd is the path or under it. */
|
||||
function belongsTo(cwd: string | null, projectPath: string): boolean {
|
||||
if (cwd === null) return false
|
||||
if (cwd === projectPath) return true
|
||||
return cwd.startsWith(projectPath + path.sep)
|
||||
}
|
||||
|
||||
function toSessionRef(s: LiveSessionInfo): ProjectSessionRef {
|
||||
return {
|
||||
id: s.id,
|
||||
title: lastSegment(s.cwd),
|
||||
status: s.status,
|
||||
clientCount: s.clientCount,
|
||||
createdAt: s.createdAt,
|
||||
exited: s.exited,
|
||||
}
|
||||
}
|
||||
|
||||
function matchSessions(projectPath: string, live: readonly LiveSessionInfo[]): ProjectSessionRef[] {
|
||||
return live.filter((s) => belongsTo(s.cwd, projectPath)).map(toSessionRef)
|
||||
}
|
||||
|
||||
function sortProjects(projects: readonly ProjectInfo[]): ProjectInfo[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
if (a.lastActiveMs !== b.lastActiveMs) {
|
||||
if (a.lastActiveMs === undefined) return 1 // undefined sorts last
|
||||
if (b.lastActiveMs === undefined) return -1
|
||||
return b.lastActiveMs - a.lastActiveMs // most-recent first
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the host's projects and attach the currently-running sessions.
|
||||
* `liveSessions` is INJECTED (e.g. `manager.list()`) so this stays pure-testable.
|
||||
* Disk discovery is cached; the session merge always runs fresh.
|
||||
*/
|
||||
export async function buildProjects(
|
||||
cfg: Config,
|
||||
liveSessions: readonly LiveSessionInfo[],
|
||||
): Promise<ProjectInfo[]> {
|
||||
const base = await discoverProjects(cfg)
|
||||
const withSessions = base.map((p) => ({
|
||||
...p,
|
||||
sessions: matchSessions(p.path, liveSessions),
|
||||
}))
|
||||
return sortProjects(withSessions).slice(0, MAX_PROJECTS)
|
||||
}
|
||||
Reference in New Issue
Block a user