Files
web-terminal/src/http/projects.ts
Yaojia Wang 7b4adf5072 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).
2026-06-30 11:04:49 +02:00

356 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 13 (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 13: 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 13, 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)
}