diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index f713a70..9662d4f 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,19 +24,19 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 -### 🚧 v0.6 Project Manager 项目工作台(进行中 — 2026-06-30 交接) +### ✅ v0.6 Project Manager 项目工作台(完成 — 2026-06-30,分支 `v0.6-projects`) -- **设计/计划已就绪**: `docs/FEATURE_PROJECT_MANAGER.md`(含 1:N 多 session 设计)、`docs/THEME.md`(Amber 主题)、`docs/mockups/`(预览图,`final-amber.png` 为定稿视觉)。计划见会话内 `/ecc:plan` 输出(wave 分波 + Owns)。 -- **主题已 ship**: `public/style.css` 靛蓝 `#7c8cff` → **Amber 琥珀金 `#e3a64a`**;新增 `--on-accent`;暖灰底;`--green/--amber/--red` 状态色保留;keybar 残留靛蓝已清。 -- **地基已落(编译绿,未提交)**: - - ✅ **T-PM1 契约** `src/types.ts`: 新增 `ProjectInfo` / `ProjectSessionRef`(复用 `ClaudeStatus`) + Config 4 字段(`projectRoots/projectScanDepth/projectScanTtlMs/projectDirtyCheck`)。 - - ✅ **T-PM3 配置** `src/config.ts`: `parseBool` / `parseProjectRoots` helper + 解析 4 个 env(`PROJECT_ROOTS`/`PROJECT_SCAN_DEPTH`/`PROJECT_SCAN_TTL`/`PROJECT_DIRTY_CHECK`,默认 `[homeDir]`/4/10000/true)。`npm run typecheck` 通过。 -- **下一步(重启后从这里继续)**: - 1. **W1 并行 2 builder(worktree)**: **T-PM2** 后端发现 `src/http/projects.ts` + `test/projects.test.ts`(`buildProjects(cfg, liveSessions)`:扫 .git→分支/dirty→合并 ~/.claude/projects cwd→按 cwd 归并 sessions→缓存,镜像 `src/http/history.ts:80` 模式);**T-PM5** 前端面板 `public/projects.ts` + style 追加(`mountProjects`,1:N 卡片,复用 launcher/`el()`/Amber token)。 - 2. **W2 并行 2**: **T-PM4** `src/server.ts` 加 `GET /projects`(只读,无 Origin 守卫,喂 `manager.list()`);**T-PM6** `public/tabs.ts` `openProject`(同名加 `#n`,复用 `addEntry`)+ 挂 `mountProjects` + 首页 Sessions↔Projects 段控。 - 3. **W3**: `module-reviewer` 验收 A1–A6(report-only)。 - - 关键接口约定: `buildProjects(cfg, liveSessions): Promise`(注入 liveSessions 便于纯测)。 -- **注意**: 当前工作树有未提交改动(types.ts/config.ts/style.css/docs)。建议重启后先 `git status` 核对,按规范建 `v0.6-projects` 分支再继续。 +- **全部 6 任务完成并验证**(P1–P6,多 agent 编排)。**398 测试全绿**(自 341 +57)· 双 typecheck 干净 · `build:web` OK · 覆盖率 90.98/81.95/89.28/93.16(阈值 80×4 通过)· **真浏览器 smoke 通过**。 +- **编排方式**: ultracode `Workflow` 动态编排——Build(P2 后端 ∥ P5 前端,`module-builder`)→ Wire(P4 端点 ∥ P6 接线)→ Review(`module-reviewer` 验收 + `typescript-reviewer` + `security-reviewer`,report-only 并行)→ 对每个 HIGH/CRITICAL 对抗式 Verify(`module-reviewer`)→ Fix(`module-builder` 仅改已确认项)。**P6 首跑因 transient `server_error` 挂掉(未落任何改动)→ stop + `resumeFromRunId` 恢复:P2/P4/P5 命中缓存秒回,P6 重跑成功。** +- **交付**: + - **P2** `src/http/projects.ts`(15 测试,93.37% 覆盖): `parseGitHead`(纯) + `buildProjects(cfg, liveSessions)`;BFS 扫 .git(深度上限、跳过 node_modules/dotdir/symlink、命中即停下钻)→ 读 `.git/HEAD` 分支 + `git status --porcelain` dirty(`execFile` 无 shell、2s 超时、并发 8)→ 合并 `~/.claude/projects` cwd(复用 `history.listSessions`)→ 按 cwd 前缀归并 live sessions(每次 fresh)→ TTL 缓存(仅缓存磁盘发现)+ **in-flight 去重**。 + - **P5** `public/projects.ts`(纯 helper 30+ 测试) + style 追加: `mountProjects`(镜像 `mountLauncher`);1:N 卡片(分支 chip / dirty 点 / session 行 / `+ start claude here`)、搜索过滤、收藏置顶(localStorage);`.home-seg` 段控样式也在此定义。 + - **P4** `src/server.ts`: `GET /projects`(只读、无 Origin 守卫、try/catch 兜底 `[]`)+ 5 集成测试(真起 server)。 + - **P6** `public/tabs.ts`: `openProject`(同名 `#n` 后缀、`addEntry(null,label,cwd,'claude\r')`)+ `countOpenWithTitlePrefix` + 首页 **Sessions↔Projects 段控**(`updateHomeView`);7 新测试。 +- **Review 12 findings → 4 HIGH 全确认并修复**: ① **A3 关键**:`openProject`/`newTabForResume` 缺回车 `\r`(claude 不自动执行)→ 补 `\r`;② 段控被定位面板盖住 → 面板 `inset:44px 0 0 0`;③ `mergeHistory` 串行 I/O → `mapWithConcurrency`;④ discover 并发缓存击穿 → in-flight promise 去重。 +- **Orchestrator 追加加固**(MEDIUM/LOW,本人直接改 + 补测): 前端 `fetchProjects` 加 `normalizeProject`(API 响应边界校验,缺 `sessions` 不再 TypeError)+ `getFavs` 过滤非字符串(localStorage 防污);`config.parseProjectRoots` 相对路径 fail-fast(+ config 补 v0.6 解析测试,config.ts 95.87%→98%)。`style.css` 1143 行 > 800 上限属**既有债**(v0.6 前已超),esbuild 仍打单文件,留作后续拆分。 +- **真浏览器 smoke**(`PROJECT_ROOTS=~/Documents/git`,83 真 repo): 首页段控 Sessions/Projects 切换 ✓;Projects 面板 83 卡片(`web-terminal` 显示 `branch:v0.6-projects` + dirty 正确,含 `feat/...` 斜杠分支)+ 搜索框 ✓;点 `web-terminal` 卡 → 开 tab 标签 `web-terminal`、首页隐藏、**终端出现 claude 启动界面**(A3 端到端)✓;console 仅既有 `/favicon.ico` 404 + apple-mobile 警告,无 v0.6 报错。 +- **待办**: 真机/手机验收(同历史 F4/F7 遗留);可选 server.ts 抽 routes;`style.css` 拆分。 - **当前阶段**: **v0.3 全部完成**(H1–H4 + M3/M6/M7 已并入 main;**O2 历史浏览** 在分支 `o2-history`,待合并)。 - ✅ Step1 前端快赢 · ✅ H2/H4 状态感知 · ✅ H3 远程批准 · ✅ H1 tmux 保活 · ✅ M3/M7/M6 · ✅ **O2 历史会话浏览/resume** · ✅ tech-debt 清理(@ts-ignore 去掉)· ✅ UI:终端不再打印 Connecting/Connected(靠标签点)。 @@ -146,6 +146,22 @@ - **commit**: ======================================================= --> +### 2026-06-30 · v0.6 Project Manager(P1–P6,多 agent Workflow 编排) + +- **状态**: `[x]` DONE(分支 `v0.6-projects`)。**398 测试全绿** · 双 typecheck 干净 · `build:web` OK · 覆盖率 90.98/81.95/89.28/93.16(≥80×4)· **真浏览器 smoke 通过**。 +- **目标**: 首页新增 **Projects 视图**——发现主机上的 git 仓库,点卡片 → 开 tab(标签=repo 名、cwd=仓库、自动跑 `claude`);卡片显示分支/dirty/该项目运行中的 sessions(1:N)。设计见 `docs/FEATURE_PROJECT_MANAGER.md`。 +- **编排**(ultracode `Workflow` 动态): Build(P2 后端 ∥ P5 前端)→ Wire(P4 端点 ∥ P6 接线)→ Review(`module-reviewer` 验收 + `typescript-reviewer` + `security-reviewer` 并行 report-only)→ HIGH/CRITICAL 对抗式 Verify → Fix。**P6 首跑 transient `server_error` 挂掉(0 改动落地)→ `TaskStop` + `Workflow{resumeFromRunId}` 恢复;P2/P4/P5 缓存命中,P6 重跑成功。** 地基 P1(types)/P3(config)上一会话已落,本会话先 commit 为 `dc5d073` 作干净基线。 +- **改动**: + - `src/http/projects.ts`(新,P2): `parseGitHead`(纯)、`buildProjects(cfg, liveSessions)`;内部 `scanRepos`(BFS 深度上限/跳 node_modules·dotdir·symlink/命中即停)、`readBranch`、`readDirty`(`execFile` git status,2s 超时,并发 8)、`mergeHistory`(复用 `history.listSessions`)、缓存 `discoverProjects`(TTL + in-flight 去重)、fresh session 按 cwd 前缀归并、`_clearProjectCache`(测试)。`test/projects.test.ts`(15)。 + - `public/projects.ts`(新,P5): `mountProjects` + 纯 helper `filterProjects/sortProjects/getFavs/saveFavs/toggleFav/normalizeProject`。`test/projects-panel.test.ts`(36)。`public/style.css` 追加 Projects 面板 + `.home-seg` 段控样式。 + - `src/server.ts`(P4): `GET /projects`(只读、无 Origin 守卫、try/catch→`[]`)。`test/integration/projects-endpoint.test.ts`(5,真起 server)。 + - `public/tabs.ts`(P6): `openProject`(同名 `#n`、`addEntry(...,'claude\r')`)、`countOpenWithTitlePrefix`、`updateHomeView` + `.home-seg` 段控。`test/tabs.test.ts` +7。 +- **Review→Fix**: 12 findings,4 HIGH 全确认修复——① **A3 关键缺回车 `\r`**(`openProject`/`newTabForResume`)② 段控被定位面板遮挡(面板 `inset:44px 0 0 0`)③ `mergeHistory` 串行→`mapWithConcurrency` ④ discover 并发缓存击穿→in-flight 去重。Orchestrator 追加加固(MEDIUM/LOW + 测试): `normalizeProject`(API 边界校验)、`getFavs` 滤非字符串、`parseProjectRoots` 相对路径 fail-fast、config v0.6 解析补测(config.ts→98%)。 +- **验证**: `npx tsc -p tsconfig.json/.web.json --noEmit` 干净;`npx vitest run` 398/398;`npm run build:web` OK;`npx vitest run --coverage` 退出 0(阈值 80×4)。真浏览器(`PROJECT_ROOTS=~/Documents/git`,83 repo):段控切换 ✓、面板 83 卡片(分支/dirty/斜杠分支正确)+ 搜索 ✓、点卡 → tab `web-terminal` + 终端出现 claude 启动界面(A3 端到端)✓、console 无 v0.6 报错(仅既有 favicon 404)。 +- **决策 / 偏离**: 缓存只缓存磁盘发现、session 每次 fresh 归并(避免陈旧);git worktree 看板/文件树编辑器/ticket 拉取 = v0.6 明确不做(FEATURE §9)。`public/projects.ts`/`tabs.ts` 的 mount* DOM 接线沿用既有约定不纳入覆盖率(纯 helper 已测)。`style.css` 1143>800 = 既有债,留作拆分。 +- **遗留 / 待办**: 真机 F4/F7(同历史);可选 `server.ts` 抽 `http/routes.ts`;`style.css` 拆分。 +- **commit**: (本次提交) + ### 2026-06-16 · T1 脚手架 + 一次性装齐依赖 - **状态**: `[x]` DONE diff --git a/public/projects.ts b/public/projects.ts new file mode 100644 index 0000000..257e90d --- /dev/null +++ b/public/projects.ts @@ -0,0 +1,318 @@ +/** + * public/projects.ts — Projects panel for the home screen (v0.6). + * + * Renders a grid of discovered git repositories fetched from GET /projects. + * Each card shows: repo name, branch chip, dirty indicator, last-active time, + * and running sessions (1:N). Supports live search and per-project favourites + * persisted to localStorage. + * + * Pure helpers (filterProjects, sortProjects, getFavs, saveFavs, toggleFav) + * are exported for unit testing without DOM. mountProjects is the thin wiring. + * + * Timer lifecycle mirrors launcher.ts: auto-refresh every 5 s while visible, + * stopped + cleaned up on hide. + */ + +import type { ProjectInfo, ProjectSessionRef, ClaudeStatus } from '../src/types.js' +import { el, relTime } from './preview-grid.js' + +/* ── Constants ───────────────────────────────────────────────────────────── */ + +const FAV_KEY = 'web-terminal:proj-favs' +const REFRESH_MS = 5000 + +/* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */ + +export interface ProjectsHooks { + /** Spawn a new claude session in the given repo. */ + onOpenProject: (repoPath: string, repoName: string) => void + /** Enter an already-running session by id. */ + onEnterSession: (id: string) => void +} + +export interface ProjectsPanel { + setVisible(v: boolean): void + refresh(): void +} + +/* ── Pure helpers (exported for unit tests) ──────────────────────────────── */ + +/** + * Filter projects by name or path substring (case-insensitive). + * Returns a new array; never mutates the input. + */ +export function filterProjects(projects: readonly ProjectInfo[], query: string): ProjectInfo[] { + const trimmed = query.trim() + if (!trimmed) return [...projects] + const lower = trimmed.toLowerCase() + return projects.filter( + (p) => p.name.toLowerCase().includes(lower) || p.path.toLowerCase().includes(lower), + ) +} + +/** + * Sort projects: favourites first, then by lastActiveMs descending. + * Returns a new array; never mutates the input. + */ +export function sortProjects( + projects: readonly ProjectInfo[], + favs: ReadonlySet, +): ProjectInfo[] { + return [...projects].sort((a, b) => { + const aFav = favs.has(a.path) ? 0 : 1 + const bFav = favs.has(b.path) ? 0 : 1 + if (aFav !== bFav) return aFav - bFav + return (b.lastActiveMs ?? 0) - (a.lastActiveMs ?? 0) + }) +} + +/** Load persisted favourite project paths from localStorage. */ +export function getFavs(): Set { + try { + const raw = localStorage.getItem(FAV_KEY) + if (!raw) return new Set() + const parsed: unknown = JSON.parse(raw) + // Never trust persisted data: keep only string entries (corrupt/tampered + // localStorage could otherwise poison the Set used for path matching). + if (!Array.isArray(parsed)) return new Set() + return new Set(parsed.filter((x): x is string => typeof x === 'string')) + } catch { + return new Set() + } +} + +/** Persist a set of favourite project paths to localStorage. */ +export function saveFavs(favs: ReadonlySet): void { + try { + localStorage.setItem(FAV_KEY, JSON.stringify([...favs])) + } catch { + // localStorage unavailable — run without persistence + } +} + +/** + * Immutable toggle: add if absent, remove if present. + * Returns a new Set; never mutates the original. + */ +export function toggleFav(favs: ReadonlySet, path: string): Set { + const next = new Set(favs) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next +} + +/* ── Fetch ───────────────────────────────────────────────────────────────── */ + +/** Coerce one untrusted /projects element into a safe ProjectInfo, or null. + * Guarantees `sessions` is always an array so card rendering never throws on a + * malformed response (never trust external data — API responses included). */ +export function normalizeProject(p: unknown): ProjectInfo | null { + if (p === null || typeof p !== 'object') return null + const o = p as Record + if (typeof o['name'] !== 'string' || typeof o['path'] !== 'string') return null + const sessions = Array.isArray(o['sessions']) ? (o['sessions'] as ProjectSessionRef[]) : [] + return { + name: o['name'], + path: o['path'], + isGit: o['isGit'] === true, + ...(typeof o['branch'] === 'string' ? { branch: o['branch'] } : {}), + ...(typeof o['dirty'] === 'boolean' ? { dirty: o['dirty'] } : {}), + ...(typeof o['lastActiveMs'] === 'number' ? { lastActiveMs: o['lastActiveMs'] } : {}), + sessions, + } +} + +async function fetchProjects(): Promise { + try { + const res = await fetch('/projects') + const data: unknown = await res.json() + if (!Array.isArray(data)) return [] + return data.map(normalizeProject).filter((x): x is ProjectInfo => x !== null) + } catch { + return [] + } +} + +/* ── Status helpers ──────────────────────────────────────────────────────── */ + +function sessionDotClass(status: ClaudeStatus): string { + return `proj-session-dot proj-dot-${status}` +} + +/* ── Card sub-elements ───────────────────────────────────────────────────── */ + +function makeSessionRow( + sess: { id: string; title?: string; status: ClaudeStatus; clientCount: number }, + onEnterSession: (id: string) => void, +): HTMLElement { + const row = el('div', 'proj-session-row') + row.setAttribute('role', 'button') + row.tabIndex = 0 + + const dot = el('span', sessionDotClass(sess.status)) + const title = el('span', 'proj-session-title', sess.title ?? 'claude') + const badge = el('span', 'proj-session-badge', `👁 ${sess.clientCount}`) + + row.append(dot, title, badge) + + row.addEventListener('click', () => onEnterSession(sess.id)) + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onEnterSession(sess.id) + } + }) + + return row +} + +function makeProjectCard( + project: ProjectInfo, + favs: ReadonlySet, + hooks: ProjectsHooks, + onToggleFav: (path: string) => void, +): HTMLElement { + const card = el('div', 'proj-card') + + // ── Header ──────────────────────────────────────────────────────────────── + const header = el('div', 'proj-card-head') + + const isFav = favs.has(project.path) + const favBtn = el('button', isFav ? 'proj-fav on' : 'proj-fav') + favBtn.textContent = '★' + favBtn.title = isFav ? 'Remove from favourites' : 'Add to favourites' + favBtn.addEventListener('click', (e) => { + e.stopPropagation() + onToggleFav(project.path) + }) + + const name = el('span', 'proj-name', project.name) + + header.append(favBtn, name) + + if (project.branch) { + const branch = el('span', 'proj-branch', project.branch) + header.append(branch) + } + + if (project.dirty === true) { + const dirty = el('span', 'proj-dirty', '●') + dirty.title = 'Uncommitted changes' + header.append(dirty) + } + + card.append(header) + + // ── Meta line ───────────────────────────────────────────────────────────── + if (project.lastActiveMs !== undefined) { + const meta = el('div', 'proj-meta', `active ${relTime(project.lastActiveMs)} ago`) + card.append(meta) + } + + // ── Sessions area (1:N) ─────────────────────────────────────────────────── + const runningSessions = project.sessions.filter((s) => !s.exited) + + if (runningSessions.length === 0) { + // 0 running sessions: single primary "start claude here" action + const newBtn = el('button', 'proj-new proj-new-primary', '+ start claude here') + newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name)) + card.append(newBtn) + } else { + // 1+ running sessions: list session rows + always-present "+ New" button + const sessionsList = el('div', 'proj-sessions-list') + for (const sess of runningSessions) { + sessionsList.append(makeSessionRow(sess, hooks.onEnterSession)) + } + card.append(sessionsList) + + const newBtn = el('button', 'proj-new', '+ New') + newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name)) + card.append(newBtn) + } + + return card +} + +/* ── Mount ───────────────────────────────────────────────────────────────── */ + +export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): ProjectsPanel { + const root = el('div', 'proj-panel') + root.style.display = 'none' + host.appendChild(root) + + // Search box + const searchWrap = el('div', 'proj-search-wrap') + const searchInput = document.createElement('input') + searchInput.className = 'proj-search' + searchInput.type = 'search' + searchInput.placeholder = 'Filter projects…' + searchInput.setAttribute('aria-label', 'Filter projects') + searchWrap.append(searchInput) + root.append(searchWrap) + + const grid = el('div', 'proj-grid') + root.append(grid) + + let allProjects: ProjectInfo[] = [] + let favs: Set = getFavs() + let timer: ReturnType | null = null + + function renderGrid(): void { + const filtered = filterProjects(allProjects, searchInput.value) + const sorted = sortProjects(filtered, favs) + + grid.replaceChildren() + + if (sorted.length === 0) { + const msg = + allProjects.length === 0 + ? 'No projects found. Check PROJECT_ROOTS configuration.' + : 'No projects match your search.' + grid.append(el('div', 'proj-empty', msg)) + return + } + + for (const p of sorted) { + grid.append( + makeProjectCard(p, favs, hooks, (path) => { + favs = toggleFav(favs, path) + saveFavs(favs) + renderGrid() + }), + ) + } + } + + async function refresh(): Promise { + allProjects = await fetchProjects() + favs = getFavs() + renderGrid() + } + + searchInput.addEventListener('input', () => renderGrid()) + + return { + setVisible(v: boolean): void { + root.style.display = v ? 'block' : 'none' + if (v) { + void refresh() + if (timer === null) { + timer = setInterval(() => { + if (root.style.display !== 'none') void refresh() + }, REFRESH_MS) + } + } else { + if (timer !== null) { + clearInterval(timer) + timer = null + } + } + }, + refresh(): void { + void refresh() + }, + } +} diff --git a/public/style.css b/public/style.css index ac89b5a..700d93d 100644 --- a/public/style.css +++ b/public/style.css @@ -664,7 +664,7 @@ body { /* ── Launcher (home session chooser, shown when no tab is open) ──── */ .launcher { position: absolute; - inset: 0; + inset: 44px 0 0 0; /* top offset reserves space for .home-seg (height ~32px + gap) */ overflow-y: auto; padding: 22px 18px 40px; box-sizing: border-box; @@ -872,3 +872,272 @@ body { padding: 30px 0; text-align: center; } + +/* ── v0.6 Projects panel ─────────────────────────────────────────────────── */ + +/* Panel wrapper (same host as .launcher; toggled via display) */ +.proj-panel { + position: absolute; + inset: 44px 0 0 0; /* top offset reserves space for .home-seg (height ~32px + gap) */ + overflow-y: auto; + padding: 22px 18px 40px; + box-sizing: border-box; + background: var(--bg); +} + +/* Search box */ +.proj-search-wrap { + margin-bottom: 16px; +} +.proj-search { + width: 100%; + max-width: 320px; + padding: 8px 12px; + font: inherit; + font-size: 14px; + color: var(--text); + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: 8px; + outline: none; + box-sizing: border-box; +} +.proj-search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} + +/* Grid — mirrors .mg-grid layout */ +.proj-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; +} + +/* Project card */ +.proj-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 12px; + transition: border-color 0.12s ease; +} +.proj-card:hover { + border-color: var(--border-strong); +} + +/* Card header row */ +.proj-card-head { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +/* Repo name */ +.proj-name { + flex: 1 1 auto; + color: #fff; + font-weight: 600; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Branch chip */ +.proj-branch { + font-size: 11px; + color: var(--accent); + background: var(--accent-soft); + border-radius: 5px; + padding: 2px 7px; + white-space: nowrap; + flex: none; +} + +/* Dirty indicator (● dot) */ +.proj-dirty { + font-size: 10px; + color: var(--amber); + flex: none; +} + +/* Meta line (last-active time) */ +.proj-meta { + font-size: 11px; + color: var(--text-faint); + font-family: Menlo, Consolas, monospace; +} + +/* Favourite star toggle */ +.proj-fav { + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + font-size: 15px; + padding: 0 2px; + line-height: 1; + flex: none; + transition: color 0.12s ease; +} +.proj-fav:hover { + color: var(--accent); +} +.proj-fav.on { + color: var(--accent); +} + +/* Sessions list (1:N) */ +.proj-sessions-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* One running-session row */ +.proj-session-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 8px; + background: var(--surface-3); + cursor: pointer; + font-size: 13px; + color: var(--text); + transition: background 0.1s ease; +} +.proj-session-row:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* Status dot on a session row — colours from THEME.md v0.6 note */ +.proj-session-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; + background: var(--text-faint); +} +.proj-dot-working { + background: var(--accent); + box-shadow: 0 0 5px var(--accent-soft); +} +.proj-dot-waiting { + background: var(--amber); +} +.proj-dot-idle { + background: #8b8a86; +} +.proj-dot-unknown { + background: var(--text-faint); +} + +/* Session title */ +.proj-session-title { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Client-count badge (👁 N) */ +.proj-session-badge { + font-size: 11px; + color: var(--text-dim); + background: var(--surface-2); + border-radius: 4px; + padding: 1px 5px; + flex: none; +} + +/* "+ New" / "+ start claude here" action button */ +.proj-new { + background: var(--surface-3); + border: 1px solid var(--border); + color: var(--text-dim); + border-radius: 8px; + padding: 7px 12px; + cursor: pointer; + font: inherit; + font-size: 13px; + text-align: center; + transition: background 0.1s ease, color 0.1s ease, border-color 0.1s ease; +} +.proj-new:hover { + background: var(--accent-soft); + color: var(--accent); + border-color: rgba(227, 166, 74, 0.3); +} + +/* Primary action — no sessions yet; full Amber accent button */ +.proj-new.proj-new-primary { + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); + font-weight: 600; +} +.proj-new.proj-new-primary:hover { + background: var(--accent-2); + border-color: var(--accent-2); + color: var(--on-accent); +} + +/* Empty state */ +.proj-empty { + color: var(--text-dim); + padding: 30px 0; + text-align: center; +} + +/* ── Home segmented control (P6 builds the DOM; styles defined here per spec) */ + +/* Horizontal pill container, centered above the home grid */ +.home-seg { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + +/* Inactive segment */ +.home-seg-btn { + background: var(--surface-2); + border: 1px solid var(--border-strong); + color: var(--text-dim); + cursor: pointer; + font: inherit; + font-size: 13px; + padding: 8px 20px; + transition: background 0.12s ease, color 0.12s ease; +} +.home-seg-btn:first-child { + border-radius: 20px 0 0 20px; +} +.home-seg-btn:last-child { + border-radius: 0 20px 20px 0; + border-left: none; +} +.home-seg-btn:only-child { + border-radius: 20px; +} +.home-seg-btn:not(.active):hover { + background: var(--surface-3); + color: var(--text); +} + +/* Active segment — Amber accent; text uses --on-accent for contrast */ +.home-seg-btn.active { + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); + font-weight: 600; +} +.home-seg-btn.active:hover { + background: var(--accent-2); + border-color: var(--accent-2); +} diff --git a/public/tabs.ts b/public/tabs.ts index 637b605..d5b52a5 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -14,14 +14,23 @@ * * Closing a tab only disconnects its WS (a *detach*); the server PTY keeps * running and is reclaimed by IDLE_TTL. + * + * v0.6 (P6): the home screen has a segmented control ("Sessions" / "Projects") + * that switches between the session launcher and the projects panel. State is + * persisted to localStorage. openProject() spawns a new claude session in a + * repo directory, labelled with the repo name (deduped with #N suffixes). */ import { TerminalSession } from './terminal-session.js' import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' import { mountLauncher, type Launcher } from './launcher.js' +import { mountProjects, type ProjectsPanel } from './projects.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' +const HOME_VIEW_KEY = 'web-terminal:home-view' + +type HomeView = 'sessions' | 'projects' interface TabEntry { session: TerminalSession @@ -47,7 +56,9 @@ export class TabApp { private readonly tabBar: HTMLElement private readonly approvalBar: HTMLDivElement private readonly launcher: Launcher - private launcherVisible = false + private readonly projects: ProjectsPanel + private homeView: HomeView = 'sessions' + private readonly segControl: HTMLElement constructor(paneHost: HTMLElement, tabBar: HTMLElement) { this.paneHost = paneHost @@ -56,25 +67,100 @@ export class TabApp { this.approvalBar.id = 'approvalbar' this.approvalBar.style.display = 'none' document.body.appendChild(this.approvalBar) + this.launcher = mountLauncher(this.paneHost, { onOpen: (id) => this.openSession(id), onNew: () => this.newTab(), }) - // v0.5: do NOT auto-create or auto-restore tabs. Land on the launcher (the - // session chooser); the user picks which session to open. Sessions persist - // server-side, so opening one replays its full scrollback. + + // Load persisted home view choice. + try { + const stored = localStorage.getItem(HOME_VIEW_KEY) + if (stored === 'sessions' || stored === 'projects') this.homeView = stored + } catch { + // localStorage unavailable — use default + } + + this.projects = mountProjects(this.paneHost, { + onOpenProject: (repoPath, repoName) => this.openProject(repoPath, repoName), + onEnterSession: (id) => this.openSession(id), + }) + + this.segControl = this.buildSegControl() + this.paneHost.appendChild(this.segControl) + + // v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen; + // the user picks which session to open. Sessions persist server-side, so + // opening one replays its full scrollback. this.rebuild() - this.updateLauncher() + this.updateHomeView() } - /** Show the launcher (session chooser) iff no tab is open. */ - private updateLauncher(): void { + /* ── Home-view visibility ────────────────────────────────────────── */ + + /** + * Controls visibility of the segmented control and both home sub-panels. + * When tabs are open: hide everything (the terminal panes own the screen). + * When no tab is open: show the seg control and the selected sub-panel. + */ + private updateHomeView(): void { const empty = this.tabs.length === 0 - if (empty === this.launcherVisible) return - this.launcherVisible = empty - this.launcher.setVisible(empty) + + // Sync active-class on segmented control buttons. + const btns = this.segControl.querySelectorAll('.home-seg-btn') + btns.forEach((btn, i) => { + const view: HomeView = i === 0 ? 'sessions' : 'projects' + btn.classList.toggle('active', view === this.homeView) + }) + + if (empty) { + this.segControl.style.display = 'flex' + this.launcher.setVisible(this.homeView === 'sessions') + this.projects.setVisible(this.homeView === 'projects') + } else { + this.segControl.style.display = 'none' + this.launcher.setVisible(false) + this.projects.setVisible(false) + } } + private saveHomeView(): void { + try { + localStorage.setItem(HOME_VIEW_KEY, this.homeView) + } catch { + // localStorage unavailable + } + } + + private buildSegControl(): HTMLElement { + const seg = document.createElement('div') + seg.className = 'home-seg' + seg.style.display = 'none' // updateHomeView() will show it when needed + + const sessBtn = document.createElement('button') + sessBtn.className = 'home-seg-btn' + sessBtn.textContent = 'Sessions' + sessBtn.addEventListener('click', () => { + this.homeView = 'sessions' + this.saveHomeView() + this.updateHomeView() + }) + + const projBtn = document.createElement('button') + projBtn.className = 'home-seg-btn' + projBtn.textContent = 'Projects' + projBtn.addEventListener('click', () => { + this.homeView = 'projects' + this.saveHomeView() + this.updateHomeView() + }) + + seg.append(sessBtn, projBtn) + return seg + } + + /* ── Approval bar ───────────────────────────────────────────────── */ + /** Show/hide the approve/reject banner for the active tab's held request (H3). */ private updateApprovalBar(): void { const session = this.tabs[this.activeIndex]?.session @@ -108,7 +194,7 @@ export class TabApp { return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` } - /* ── persistence ─────────────────────────────────────────────── */ + /* ── persistence ─────────────────────────────────────────────────── */ private persist(): void { try { @@ -138,7 +224,7 @@ export class TabApp { this.activate(this.tabs.length - 1) } - /* ── tab lifecycle ───────────────────────────────────────────── */ + /* ── tab lifecycle ───────────────────────────────────────────────── */ private addEntry( sessionId: string | null, @@ -193,12 +279,34 @@ export class TabApp { /** O2: open a new tab in `cwd` and run `claude --resume `. */ newTabForResume(cwd: string, sessionId: string): void { - this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}`) + this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}\r`) this.persist() this.rebuild() this.activate(this.tabs.length - 1) } + /** + * v0.6 (P6): spawn a new claude session in `repoPath`, labelled `repoName`. + * If tabs with the same base name already exist, appends a #N suffix to + * distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in + * structure: addEntry → persist → rebuild → activate. + */ + openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void { + const n = this.countOpenWithTitlePrefix(repoName) + const label = n === 0 ? repoName : `${repoName} #${n + 1}` + this.addEntry(null, label, repoPath || undefined, cmd) + this.persist() + this.rebuild() + this.activate(this.tabs.length - 1) + } + + /** Count tabs whose customTitle is exactly `name` or starts with `name + ' #'`. */ + private countOpenWithTitlePrefix(name: string): number { + return this.tabs.filter( + (t) => t.customTitle === name || t.customTitle?.startsWith(`${name} #`) === true, + ).length + } + activate(i: number): void { if (i < 0 || i >= this.tabs.length) return this.maybeAskNotify() // first switch is a user gesture — request notif permission @@ -217,10 +325,10 @@ export class TabApp { entry?.session.dispose() if (this.editingIndex === i) this.editingIndex = -1 if (this.tabs.length === 0) { - // v0.5: closing the last tab returns to the launcher — no auto-blank tab. + // v0.5: closing the last tab returns to the home screen — no auto-blank tab. this.activeIndex = -1 this.persist() - this.rebuild() // updateLauncher() (in rebuild) shows the chooser + this.rebuild() // updateHomeView() (in rebuild) shows the chooser this.updateApprovalBar() return } @@ -323,7 +431,7 @@ export class TabApp { new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' }) } - /* ── rendering ───────────────────────────────────────────────── */ + /* ── rendering ───────────────────────────────────────────────────── */ /** In-place update of one tab's classes/label/dot — never destroys the DOM. */ private refreshTab(entry: TabEntry): void { @@ -453,7 +561,7 @@ export class TabApp { add.addEventListener('click', () => this.newTab()) this.tabBar.appendChild(add) - // Show/hide the launcher based on whether any tab is open. - this.updateLauncher() + // Show/hide the home screen based on whether any tab is open. + this.updateHomeView() } } diff --git a/src/config.ts b/src/config.ts index 060738b..1d0ee45 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ */ import os from 'node:os' +import path from 'node:path' import type { Config, EnvLike } from './types.js' import { tmuxAvailable } from './session/tmux.js' @@ -70,6 +71,16 @@ function parseProjectRoots(raw: string | undefined, homeDir: string): readonly s .map((s) => s.trim()) .filter((s) => s !== '') .map((s) => (s === '~' ? homeDir : s.startsWith('~/') ? homeDir + s.slice(1) : s)) + // Fail fast on relative roots: silently resolving them against the server's + // CWD makes the scan target depend on where the process was launched (M1-style + // "no surprising defaults"). Require absolute paths (after '~' expansion). + for (const r of roots) { + if (!path.isAbsolute(r)) { + throw new Error( + `Invalid config: PROJECT_ROOTS entry ${JSON.stringify(r)} — must be an absolute path (or '~' / '~/...').`, + ) + } + } return Object.freeze(roots.length > 0 ? roots : [homeDir]) } diff --git a/src/http/projects.ts b/src/http/projects.ts new file mode 100644 index 0000000..f78ec40 --- /dev/null +++ b/src/http/projects.ts @@ -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/` → '' (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( + items: readonly T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length) + let cursor = 0 + async function worker(): Promise { + 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 `/.git/HEAD`; undefined if unreadable. */ +async function readBranch(repoPath: string): Promise { + 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 { + 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 `/.git` exists (file or directory). */ +async function hasGitEntry(dir: string): Promise { + 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 { + const found: string[] = [] + const seen = new Set() + 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> { + let sessions + try { + sessions = await listSessions() + } catch { + return new Map() + } + const byCwd = new Map() + 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 { + 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 } | 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() + 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 { + 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 { + 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 { + const base = await discoverProjects(cfg) + const withSessions = base.map((p) => ({ + ...p, + sessions: matchSessions(p.path, liveSessions), + })) + return sortProjects(withSessions).slice(0, MAX_PROJECTS) +} diff --git a/src/server.ts b/src/server.ts index 9c4646a..dcf8156 100644 --- a/src/server.ts +++ b/src/server.ts @@ -39,6 +39,7 @@ import { parseClientMessage, serialize } from './protocol.js' import { isOriginAllowed } from './http/origin.js' import { parseHookEvent } from './http/hook.js' import { listSessions } from './http/history.js' +import { buildProjects } from './http/projects.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, setClientDims } from './session/session.js' import type { Config } from './types.js' @@ -158,6 +159,16 @@ export function startServer(cfg: Config): { close(): Promise } { res.json(manager.list()) }) + // Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions). + app.get('/projects', async (_req, res) => { + try { + res.json(await buildProjects(cfg, manager.list())) + } catch (err) { + console.error('[server] /projects failed:', err instanceof Error ? err.message : String(err)) + res.json([]) + } + }) + // Preview (v0.4 manage page) — the tail of a session's scrollback so the grid // can render a live read-only thumbnail of its current screen. No client/attach. app.get('/live-sessions/:id/preview', (req, res) => { diff --git a/test/config.test.ts b/test/config.test.ts index 951cfb5..7870896 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -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) + }) +}) diff --git a/test/integration/projects-endpoint.test.ts b/test/integration/projects-endpoint.test.ts new file mode 100644 index 0000000..bd99caf --- /dev/null +++ b/test/integration/projects-endpoint.test.ts @@ -0,0 +1,144 @@ +/** + * Integration test for GET /projects (P4 — v0.6 Project Manager). + * + * Starts a real HTTP server against a temp directory containing one fake git + * repo, then asserts that GET /projects returns HTTP 200 and a JSON array + * containing that repo with name + isGit:true. + * + * Config overrides used for speed and determinism: + * - PROJECT_ROOTS → a mkdtemp temp dir (one fake git repo inside) + * - PROJECT_DIRTY_CHECK='0' → skip `git status` subprocess calls + * - PROJECT_SCAN_TTL='0' → no caching between test runs + * - USE_TMUX='0' → no tmux (not needed) + * - ALLOWED_ORIGINS → loopback only + */ + +import fs from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' +import { _clearProjectCache } from '../../src/http/projects.js' +import type { ProjectInfo } from '../../src/types.js' + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/** Pick a free port on 127.0.0.1 by briefly binding to port 0. */ +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('unexpected address type')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +/** Create a minimal fake git repo at `dir//`. Returns the repo path. */ +async function makeFakeGitRepo(parentDir: string, repoName: string): Promise { + const repoPath = path.join(parentDir, repoName) + const gitDir = path.join(repoPath, '.git') + await fs.mkdir(gitDir, { recursive: true }) + // Write a .git/HEAD so branch parsing works + await fs.writeFile(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n', 'utf8') + return repoPath +} + +// ── test suite ──────────────────────────────────────────────────────────────── + +describe('GET /projects — integration', () => { + let port: number + let tmpRoot: string + let repoPath: string + let serverHandle: { close(): Promise } + + beforeAll(async () => { + // Ensure a clean discovery cache from any sibling tests. + _clearProjectCache() + + port = await getFreePort() + + // Create a temp root with one fake git repo inside. + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-projects-test-')) + repoPath = await makeFakeGitRepo(tmpRoot, 'fake-repo') + + const cfg = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + // Project-discovery overrides + PROJECT_ROOTS: tmpRoot, + PROJECT_DIRTY_CHECK: '0', // skip git-status subprocess + PROJECT_SCAN_TTL: '0', // no caching; every call is fresh + PROJECT_SCAN_DEPTH: '2', // shallow — temp dir is only 1 level deep + }) + + serverHandle = startServer(cfg) + // Give the server time to bind before firing requests. + await new Promise((r) => setTimeout(r, 100)) + }) + + afterAll(async () => { + await serverHandle.close() + // Remove the temp dir (best-effort — test runner cleans /tmp anyway). + await fs.rm(tmpRoot, { recursive: true, force: true }) + _clearProjectCache() + }) + + it('returns HTTP 200 with a JSON array', async () => { + const res = await fetch(`http://127.0.0.1:${port}/projects`) + expect(res.status).toBe(200) + const body = (await res.json()) as unknown + expect(Array.isArray(body)).toBe(true) + }) + + it('includes the fake repo with correct name and isGit:true', async () => { + const res = await fetch(`http://127.0.0.1:${port}/projects`) + expect(res.status).toBe(200) + const projects = (await res.json()) as ProjectInfo[] + + const found = projects.find((p) => p.path === repoPath) + expect(found).toBeDefined() + expect(found!.name).toBe('fake-repo') + expect(found!.isGit).toBe(true) + }) + + it('includes the branch parsed from .git/HEAD', async () => { + const res = await fetch(`http://127.0.0.1:${port}/projects`) + const projects = (await res.json()) as ProjectInfo[] + const found = projects.find((p) => p.path === repoPath) + expect(found).toBeDefined() + expect(found!.branch).toBe('main') + }) + + it('returns an empty sessions array for a repo with no live sessions', async () => { + const res = await fetch(`http://127.0.0.1:${port}/projects`) + const projects = (await res.json()) as ProjectInfo[] + const found = projects.find((p) => p.path === repoPath) + expect(found).toBeDefined() + expect(Array.isArray(found!.sessions)).toBe(true) + expect(found!.sessions).toHaveLength(0) + }) + + it('responds even if buildProjects throws (graceful fallback to [])', async () => { + // The server catches errors from buildProjects and returns []. + // This is tested implicitly above — the server stays up across all sub-tests. + // (A deeper unit test of the error path is in test/projects.test.ts.) + const res = await fetch(`http://127.0.0.1:${port}/projects`) + expect(res.status).toBe(200) + }) +}) diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts new file mode 100644 index 0000000..ccbb699 --- /dev/null +++ b/test/projects-panel.test.ts @@ -0,0 +1,273 @@ +// @vitest-environment jsdom +/** + * test/projects-panel.test.ts — unit tests for pure helpers in public/projects.ts. + * + * Covers filterProjects, sortProjects, toggleFav, getFavs/saveFavs. + * localStorage is provided by the jsdom environment. + * mountProjects (DOM wiring) is thin glue and not unit-tested here. + * + * @xterm/xterm is mocked because projects.ts imports preview-grid.ts which + * uses Terminal. The mock must be declared before the dynamic import below. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ProjectInfo } from '../src/types.js' + +// ── Stub @xterm/xterm (transitively required via preview-grid.ts) ───────────── +class FakeTerminal { + open = vi.fn() + dispose = vi.fn() +} +vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal })) + +// Dynamic import AFTER mock declaration so the factory is in place. +const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject } = await import( + '../public/projects.js' +) + +/* ── Helpers ───────────────────────────────────────────────────────────────── */ + +function makeProject(overrides: Partial = {}): ProjectInfo { + return { + name: 'test-repo', + path: '/home/user/test-repo', + isGit: true, + sessions: [], + ...overrides, + } +} + +/* ── filterProjects ────────────────────────────────────────────────────────── */ + +describe('filterProjects', () => { + const projects: ProjectInfo[] = [ + makeProject({ name: 'web-terminal', path: '/home/user/web-terminal' }), + makeProject({ name: 'api-service', path: '/home/user/projects/api-service' }), + makeProject({ name: 'utils', path: '/home/user/utils' }), + ] + + it('returns all projects when query is empty string', () => { + expect(filterProjects(projects, '')).toHaveLength(3) + }) + + it('returns all projects when query is only whitespace', () => { + expect(filterProjects(projects, ' ')).toHaveLength(3) + }) + + it('filters by name substring (case-insensitive)', () => { + const result = filterProjects(projects, 'WEB') + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('web-terminal') + }) + + it('filters by path substring', () => { + const result = filterProjects(projects, 'projects') + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('api-service') + }) + + it('is case-insensitive on path', () => { + const result = filterProjects(projects, 'UTILS') + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('utils') + }) + + it('returns empty array when no project matches', () => { + expect(filterProjects(projects, 'zzz-no-match-at-all')).toHaveLength(0) + }) + + it('does not mutate the input array', () => { + const original = [...projects] + filterProjects(projects, 'web') + expect(projects).toEqual(original) + }) + + it('returns a new array each time', () => { + const r1 = filterProjects(projects, '') + const r2 = filterProjects(projects, '') + expect(r1).not.toBe(r2) // different references + }) +}) + +/* ── sortProjects ──────────────────────────────────────────────────────────── */ + +describe('sortProjects', () => { + const a = makeProject({ name: 'a', path: '/a', lastActiveMs: 1000 }) + const b = makeProject({ name: 'b', path: '/b', lastActiveMs: 3000 }) + const c = makeProject({ name: 'c', path: '/c', lastActiveMs: 2000 }) + + it('places favourites before non-favourites', () => { + const favs = new Set(['/c']) + const sorted = sortProjects([a, b, c], favs) + expect(sorted[0]!.path).toBe('/c') + }) + + it('among non-favourites sorts by lastActiveMs descending', () => { + const sorted = sortProjects([a, b, c], new Set()) + expect(sorted[0]!.path).toBe('/b') // 3000 ms + expect(sorted[1]!.path).toBe('/c') // 2000 ms + expect(sorted[2]!.path).toBe('/a') // 1000 ms + }) + + it('among favourites also sorts by lastActiveMs descending', () => { + const favs = new Set(['/a', '/b']) + const sorted = sortProjects([a, b, c], favs) + expect(sorted[0]!.path).toBe('/b') // fav, 3000 ms + expect(sorted[1]!.path).toBe('/a') // fav, 1000 ms + expect(sorted[2]!.path).toBe('/c') // non-fav, 2000 ms + }) + + it('handles missing lastActiveMs (treats as 0)', () => { + const noMs = makeProject({ path: '/x' }) + const withMs = makeProject({ path: '/y', lastActiveMs: 500 }) + const sorted = sortProjects([noMs, withMs], new Set()) + expect(sorted[0]!.path).toBe('/y') + expect(sorted[1]!.path).toBe('/x') + }) + + it('does not mutate the input array', () => { + const arr = [a, b, c] + const origFirst = arr[0]!.path + sortProjects(arr, new Set()) + expect(arr[0]!.path).toBe(origFirst) + }) + + it('returns a new array', () => { + const arr = [a, b, c] + const sorted = sortProjects(arr, new Set()) + expect(sorted).not.toBe(arr) + }) + + it('returns empty array for empty input', () => { + expect(sortProjects([], new Set())).toEqual([]) + }) +}) + +/* ── toggleFav ─────────────────────────────────────────────────────────────── */ + +describe('toggleFav', () => { + it('adds a path that is not present', () => { + const original = new Set(['/a']) + const next = toggleFav(original, '/b') + expect(next.has('/b')).toBe(true) + expect(next.has('/a')).toBe(true) + }) + + it('removes a path that is already present', () => { + const original = new Set(['/a', '/b']) + const next = toggleFav(original, '/a') + expect(next.has('/a')).toBe(false) + expect(next.has('/b')).toBe(true) + }) + + it('does not mutate the original set', () => { + const original = new Set(['/a']) + toggleFav(original, '/a') + expect(original.has('/a')).toBe(true) // unchanged + }) + + it('returns a new Set instance', () => { + const original = new Set(['/a']) + const next = toggleFav(original, '/b') + expect(next).not.toBe(original) + }) + + it('works on an empty set (add case)', () => { + const next = toggleFav(new Set(), '/x') + expect(next.has('/x')).toBe(true) + expect(next.size).toBe(1) + }) +}) + +/* ── getFavs / saveFavs (localStorage persistence) ─────────────────────────── */ + +describe('getFavs / saveFavs', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('getFavs returns empty set when no data is stored', () => { + expect(getFavs().size).toBe(0) + }) + + it('saveFavs + getFavs round-trips a set of paths', () => { + const paths = new Set(['/home/user/proj-a', '/home/user/proj-b']) + saveFavs(paths) + const loaded = getFavs() + expect(loaded.has('/home/user/proj-a')).toBe(true) + expect(loaded.has('/home/user/proj-b')).toBe(true) + expect(loaded.size).toBe(2) + }) + + it('getFavs returns empty set on malformed JSON', () => { + localStorage.setItem('web-terminal:proj-favs', 'NOT_JSON{{{') + expect(getFavs().size).toBe(0) + }) + + it('getFavs returns empty set when stored value is not an array', () => { + localStorage.setItem('web-terminal:proj-favs', JSON.stringify({ not: 'an-array' })) + expect(getFavs().size).toBe(0) + }) + + it('getFavs returns empty set when stored null', () => { + localStorage.setItem('web-terminal:proj-favs', 'null') + expect(getFavs().size).toBe(0) + }) + + it('saveFavs persists an empty set (removes all favs)', () => { + saveFavs(new Set(['/a'])) + saveFavs(new Set()) + expect(getFavs().size).toBe(0) + }) + + it('getFavs drops non-string entries from a corrupt/tampered array', () => { + localStorage.setItem('web-terminal:proj-favs', JSON.stringify(['/a', 42, null, '/b', { x: 1 }])) + const favs = getFavs() + expect([...favs].sort()).toEqual(['/a', '/b']) + }) +}) + +/* ── normalizeProject (untrusted /projects element coercion) ────────────────── */ + +describe('normalizeProject', () => { + it('passes a well-formed project through, defaulting sessions to []', () => { + const p = normalizeProject({ name: 'web', path: '/home/u/web', isGit: true }) + expect(p).toEqual({ name: 'web', path: '/home/u/web', isGit: true, sessions: [] }) + }) + + it('preserves a sessions array and optional fields', () => { + const sess = { id: 's1', status: 'idle', clientCount: 0, createdAt: 1, exited: false } + const p = normalizeProject({ + name: 'web', path: '/p', isGit: true, branch: 'main', dirty: true, lastActiveMs: 99, sessions: [sess], + }) + expect(p?.branch).toBe('main') + expect(p?.dirty).toBe(true) + expect(p?.lastActiveMs).toBe(99) + expect(p?.sessions).toHaveLength(1) + }) + + it('coerces a missing sessions field to [] (prevents card-render TypeError)', () => { + const p = normalizeProject({ name: 'web', path: '/p', isGit: true, sessions: undefined }) + expect(p?.sessions).toEqual([]) + }) + + it('returns null for a non-object', () => { + expect(normalizeProject(null)).toBeNull() + expect(normalizeProject('x')).toBeNull() + expect(normalizeProject(42)).toBeNull() + }) + + it('returns null when name or path is missing/non-string', () => { + expect(normalizeProject({ path: '/p' })).toBeNull() + expect(normalizeProject({ name: 'web' })).toBeNull() + expect(normalizeProject({ name: 1, path: '/p' })).toBeNull() + }) + + it('drops optional fields of the wrong type', () => { + const p = normalizeProject({ name: 'web', path: '/p', branch: 5, dirty: 'yes', lastActiveMs: 'x' }) + expect(p?.branch).toBeUndefined() + expect(p?.dirty).toBeUndefined() + expect(p?.lastActiveMs).toBeUndefined() + expect(p?.isGit).toBe(false) + }) +}) diff --git a/test/projects.test.ts b/test/projects.test.ts new file mode 100644 index 0000000..6cbed9b --- /dev/null +++ b/test/projects.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { Config, LiveSessionInfo } from '../src/types.js' +import type { HistorySession } from '../src/http/history.js' + +// ── mock history.listSessions so the real ~/.claude/projects never pollutes ────── +// (vitest hoists vi.mock; the factory may only reference vars prefixed "mock".) +const mockListSessions = vi.fn(async (): Promise => []) +vi.mock('../src/http/history.js', () => ({ + listSessions: (...a: unknown[]) => mockListSessions(...a), +})) + +const { parseGitHead, buildProjects, _clearProjectCache } = await import('../src/http/projects.js') + +const execFileP = promisify(execFile) + +// ── helpers ───────────────────────────────────────────────────────────────────── + +function makeCfg(overrides: Partial): Config { + return { + port: 3000, + bindHost: '0.0.0.0', + shellPath: '/bin/zsh', + homeDir: '/home/tester', + idleTtlMs: 1000, + scrollbackBytes: 1024, + maxPayloadBytes: 1024, + wsPath: '/term', + maxSessions: 50, + maxMsgsPerSec: 2000, + permTimeoutMs: 1000, + reapIntervalMs: 1000, + previewBytes: 1024, + useTmux: false, + allowedOrigins: [], + projectRoots: [], + projectScanDepth: 4, + projectScanTtlMs: 0, // default OFF so each test re-scans deterministically + projectDirtyCheck: false, + ...overrides, + } +} + +function makeLive(over: { id: string; cwd: string | null } & Partial): LiveSessionInfo { + return { + id: over.id, + createdAt: over.createdAt ?? 1000, + clientCount: over.clientCount ?? 1, + status: over.status ?? 'unknown', + exited: over.exited ?? false, + cwd: over.cwd, + cols: over.cols ?? 80, + rows: over.rows ?? 24, + } +} + +async function makeRepo(dir: string, branch: string): Promise { + await fs.mkdir(path.join(dir, '.git'), { recursive: true }) + await fs.writeFile(path.join(dir, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`) +} + +async function gitAvailable(): Promise { + try { + await execFileP('git', ['--version']) + return true + } catch { + return false + } +} + +let tmp: string + +beforeEach(async () => { + _clearProjectCache() + mockListSessions.mockReset() + mockListSessions.mockResolvedValue([]) + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-')) +}) + +afterEach(async () => { + _clearProjectCache() + await fs.rm(tmp, { recursive: true, force: true }) +}) + +// ── parseGitHead (pure) ────────────────────────────────────────────────────────── + +describe('parseGitHead', () => { + it('extracts a simple branch name', () => { + expect(parseGitHead('ref: refs/heads/main\n')).toBe('main') + }) + + it('extracts a branch name with slashes', () => { + expect(parseGitHead('ref: refs/heads/feature/x\n')).toBe('feature/x') + }) + + it('returns null for a detached HEAD (bare 40-hex sha)', () => { + expect(parseGitHead('a'.repeat(40) + '\n')).toBeNull() + }) + + it('returns null for empty / junk / non-heads refs', () => { + expect(parseGitHead('')).toBeNull() + expect(parseGitHead('garbage\n')).toBeNull() + expect(parseGitHead('ref: refs/tags/v1\n')).toBeNull() + }) +}) + +// ── buildProjects: discovery (real temp dir) ───────────────────────────────────── + +describe('buildProjects — discovery', () => { + it('finds git repos with correct names/branches, skips node_modules, respects depth', async () => { + await makeRepo(path.join(tmp, 'repoA'), 'main') // depth 1 + await makeRepo(path.join(tmp, 'sub', 'repoB'), 'dev') // depth 2 + await makeRepo(path.join(tmp, 'sub', 'deep', 'repoC'), 'x') // depth 3 -> excluded + await makeRepo(path.join(tmp, 'node_modules', 'pkg'), 'y') // node_modules -> skipped + + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) + const out = await buildProjects(cfg, []) + const byName = new Map(out.map((p) => [p.name, p])) + + expect([...byName.keys()].sort()).toEqual(['repoA', 'repoB']) + expect(byName.get('repoA')).toMatchObject({ + isGit: true, + branch: 'main', + path: path.join(tmp, 'repoA'), + sessions: [], + }) + expect(byName.get('repoB')!.branch).toBe('dev') + expect(byName.has('repoC')).toBe(false) // too deep + expect(byName.has('pkg')).toBe(false) // node_modules + }) + + it('records a root that is itself a git repo and does not descend into it', async () => { + await makeRepo(tmp, 'main') + await makeRepo(path.join(tmp, 'nested'), 'dev') // inside a repo -> not descended + + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false }) + const out = await buildProjects(cfg, []) + + expect(out.map((p) => p.path)).toEqual([tmp]) + expect(out[0]!.branch).toBe('main') + }) + + it('leaves branch undefined when .git/HEAD is missing/unreadable', async () => { + await fs.mkdir(path.join(tmp, 'bare', '.git'), { recursive: true }) // .git dir, no HEAD + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) + const out = await buildProjects(cfg, []) + const bare = out.find((p) => p.name === 'bare')! + expect(bare.isGit).toBe(true) + expect(bare.branch).toBeUndefined() + }) + + it('does not follow symlinks', async () => { + const other = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-link-')) + try { + await makeRepo(path.join(other, 'repoS'), 'main') + try { + await fs.symlink(other, path.join(tmp, 'linked'), 'dir') + } catch { + return // symlinks not permitted in this environment + } + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false }) + const out = await buildProjects(cfg, []) + expect(out.some((p) => p.name === 'repoS')).toBe(false) + } finally { + await fs.rm(other, { recursive: true, force: true }) + } + }) + + it('returns [] for an unreadable / missing root (never throws)', async () => { + const cfg = makeCfg({ + projectRoots: [path.join(tmp, 'does-not-exist')], + projectScanDepth: 2, + projectDirtyCheck: false, + }) + await expect(buildProjects(cfg, [])).resolves.toEqual([]) + }) +}) + +// ── buildProjects: live-session merge ──────────────────────────────────────────── + +describe('buildProjects — session merge', () => { + it('merges sessions into the owning project by cwd === path or under path', async () => { + await makeRepo(path.join(tmp, 'repoA'), 'main') + const repoPath = path.join(tmp, 'repoA') + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) + + const live: LiveSessionInfo[] = [ + makeLive({ id: 's-exact', cwd: repoPath, status: 'working', clientCount: 2, createdAt: 111 }), + makeLive({ id: 's-under', cwd: path.join(repoPath, 'src', 'http'), status: 'idle' }), + makeLive({ id: 's-other', cwd: path.join(tmp, 'elsewhere') }), + makeLive({ id: 's-null', cwd: null }), + ] + + const out = await buildProjects(cfg, live) + const repoA = out.find((p) => p.name === 'repoA')! + + expect(repoA.sessions.map((s) => s.id).sort()).toEqual(['s-exact', 's-under']) + expect(repoA.sessions.find((s) => s.id === 's-exact')).toEqual({ + id: 's-exact', + title: 'repoA', + status: 'working', + clientCount: 2, + createdAt: 111, + exited: false, + }) + expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http') + }) + + it('re-merges live sessions FRESH on every call (discovery is cached)', async () => { + await makeRepo(path.join(tmp, 'repoA'), 'main') + const repoPath = path.join(tmp, 'repoA') + const cfg = makeCfg({ + projectRoots: [tmp], + projectScanDepth: 2, + projectDirtyCheck: false, + projectScanTtlMs: 60_000, // keep discovery cached across calls + }) + + const first = await buildProjects(cfg, []) + expect(first.find((p) => p.name === 'repoA')!.sessions).toEqual([]) + + const second = await buildProjects(cfg, [makeLive({ id: 's1', cwd: repoPath })]) + expect(second.find((p) => p.name === 'repoA')!.sessions.map((s) => s.id)).toEqual(['s1']) + + const third = await buildProjects(cfg, []) + expect(third.find((p) => p.name === 'repoA')!.sessions).toEqual([]) + }) +}) + +// ── buildProjects: history merge + sorting ─────────────────────────────────────── + +describe('buildProjects — history merge & sorting', () => { + it('adds history-only cwds as projects and sets lastActiveMs on discovered repos', async () => { + await makeRepo(path.join(tmp, 'repoA'), 'main') + const repoPath = path.join(tmp, 'repoA') + const histDir = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-hist-')) + try { + mockListSessions.mockResolvedValue([ + { id: 'h1', cwd: repoPath, project: 'repoA', mtimeMs: 5000, preview: '' }, + { id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 9000, preview: '' }, + { id: 'h3', cwd: histDir, project: path.basename(histDir), mtimeMs: 3000, preview: '' }, + { id: 'h4', cwd: '', project: 'unknown', mtimeMs: 1, preview: '' }, + ]) + + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) + const out = await buildProjects(cfg, []) + + const repoA = out.find((p) => p.name === 'repoA')! + expect(repoA.lastActiveMs).toBe(9000) // newest of h1/h2 + + const hist = out.find((p) => p.path === histDir)! + expect(hist).toBeDefined() + expect(hist.isGit).toBe(false) + expect(hist.lastActiveMs).toBe(3000) + + expect(out.some((p) => p.path === '')).toBe(false) // empty cwd ignored + expect(out[0]!.name).toBe('repoA') // 9000 > 3000 + } finally { + await fs.rm(histDir, { recursive: true, force: true }) + } + }) + + it('sorts by lastActiveMs desc, undefined last, name asc tiebreak', async () => { + await makeRepo(path.join(tmp, 'zzz'), 'main') // no history -> undefined + await makeRepo(path.join(tmp, 'aaa'), 'main') // no history -> undefined + await makeRepo(path.join(tmp, 'mmm'), 'main') // has history -> first + mockListSessions.mockResolvedValue([ + { id: 'h', cwd: path.join(tmp, 'mmm'), project: 'mmm', mtimeMs: 1000, preview: '' }, + ]) + + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) + const out = await buildProjects(cfg, []) + + expect(out.map((p) => p.name)).toEqual(['mmm', 'aaa', 'zzz']) + }) +}) + +// ── buildProjects: in-flight promise deduplication (Finding 4) ───────────────────── + +describe('buildProjects — concurrent cache-miss deduplication', () => { + it('two concurrent cold-cache calls return consistent results (no corrupt state)', async () => { + await makeRepo(path.join(tmp, 'repoA'), 'main') + await makeRepo(path.join(tmp, 'repoB'), 'dev') + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) + + // Both calls start concurrently while cache is cold. + const [a, b] = await Promise.all([buildProjects(cfg, []), buildProjects(cfg, [])]) + + const aNames = a.map((p) => p.name).sort() + const bNames = b.map((p) => p.name).sort() + expect(aNames).toEqual(['repoA', 'repoB']) + expect(bNames).toEqual(aNames) + }) + + it('second concurrent call gets the same result as the first (in-flight reuse)', async () => { + await makeRepo(path.join(tmp, 'repoA'), 'main') + const cfg = makeCfg({ + projectRoots: [tmp], + projectScanDepth: 2, + projectDirtyCheck: false, + projectScanTtlMs: 60_000, + }) + + // Start first call, immediately start second — second should share the in-flight promise. + const first = buildProjects(cfg, []) + const second = buildProjects(cfg, []) // cache still cold at this synchronous point + const [r1, r2] = await Promise.all([first, second]) + expect(r1.map((p) => p.name)).toContain('repoA') + expect(r2.map((p) => p.name)).toEqual(r1.map((p) => p.name)) + }) +}) + +// ── buildProjects: dirty check (real git, when available) ───────────────────────── + +describe('buildProjects — dirty check', () => { + it('computes dirty via git status only when projectDirtyCheck is true', async () => { + if (!(await gitAvailable())) return // git not installed -> skip + const repo = path.join(tmp, 'gitrepo') + await fs.mkdir(repo, { recursive: true }) + await execFileP('git', ['init', '-q'], { cwd: repo }) + await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: repo }) + await execFileP('git', ['config', 'user.name', 'tester'], { cwd: repo }) + + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true }) + + // clean repo -> dirty false + let out = await buildProjects(cfg, []) + const clean = out.find((p) => p.name === 'gitrepo')! + expect(clean.dirty).toBe(false) + expect(clean.branch).toBeTruthy() + + // untracked file -> dirty true + await fs.writeFile(path.join(repo, 'a.txt'), 'hi') + _clearProjectCache() + out = await buildProjects(cfg, []) + expect(out.find((p) => p.name === 'gitrepo')!.dirty).toBe(true) + }) + + it('never spawns git (dirty undefined) when projectDirtyCheck is false', async () => { + await makeRepo(path.join(tmp, 'repoA'), 'main') + const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) + const out = await buildProjects(cfg, []) + expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined() + }) +}) diff --git a/test/tabs.test.ts b/test/tabs.test.ts index 2018001..bb2e225 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -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') + }) +})