feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring
Home screen gains a Sessions↔Projects segmented control. The Projects view discovers the host's git repos and, on click, opens a tab named after the repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project). Backend (P2/P4): - src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions): depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops descending at a repo), .git/HEAD branch parse, rate-limited `git status` dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session merge (fresh each call), TTL discovery cache with in-flight dedup. - src/server.ts — read-only GET /projects (no Origin guard, []-fallback). Frontend (P5/P6): - public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot, session rows, "+ start claude here"), live search, localStorage favourites; pure helpers extracted for unit tests. - public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'), countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView). - public/style.css — Projects panel + .home-seg control styles. Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already added; this commit adds fail-fast rejection of relative PROJECT_ROOTS. Review hardening (4 confirmed HIGH + boundary validation): - carriage-return fix so claude auto-executes (A3); seg-control z-order; parallelised history merge; concurrent-cache-miss dedup. - normalizeProject guards the /projects response; getFavs filters non-strings. Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%. Verified: both typechecks clean, full suite green, build:web OK, coverage ≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
This commit is contained in:
@@ -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<ProjectInfo[]>`(注入 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**: <hash 或 N/A>
|
||||
======================================================= -->
|
||||
|
||||
### 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
|
||||
|
||||
318
public/projects.ts
Normal file
318
public/projects.ts
Normal file
@@ -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<string>,
|
||||
): 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<string> {
|
||||
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<string> 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<string>): 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<string>, path: string): Set<string> {
|
||||
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<string, unknown>
|
||||
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<ProjectInfo[]> {
|
||||
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<string>,
|
||||
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<string> = getFavs()
|
||||
let timer: ReturnType<typeof setInterval> | 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<void> {
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
271
public/style.css
271
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);
|
||||
}
|
||||
|
||||
144
public/tabs.ts
144
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<HTMLButtonElement>('.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 <id>`. */
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
355
src/http/projects.ts
Normal file
355
src/http/projects.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* src/http/projects.ts (v0.6 Project Manager — P2) — discover host projects.
|
||||
*
|
||||
* Modelled on history.ts: fully async, best-effort, never throws to the caller.
|
||||
* Steps (see docs/FEATURE_PROJECT_MANAGER.md §4.3):
|
||||
* 1. breadth-first scan cfg.projectRoots for git repos (depth-capped, skipping
|
||||
* node_modules / dotdirs / symlinks — bounds the DoS/scan surface).
|
||||
* 2. per repo: branch from .git/HEAD (cheap, no spawn); optional dirty via
|
||||
* `git status --porcelain` (execFile, no shell, timeout + concurrency cap).
|
||||
* 3. merge recently-used cwds from ~/.claude/projects (history.ts).
|
||||
* 4. merge live sessions by cwd → ProjectInfo.sessions[] (FRESH every call).
|
||||
*
|
||||
* Steps 1–3 (expensive disk work) are cached at module scope with a TTL; the
|
||||
* step-4 session merge runs fresh on every buildProjects call (sessions churn).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import type { Config, LiveSessionInfo, ProjectInfo, ProjectSessionRef } from '../types.js'
|
||||
import { listSessions } from './history.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// ── constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const GIT_STATUS_TIMEOUT_MS = 2000 // hard kill on a slow `git status`
|
||||
const GIT_CONCURRENCY = 8 // cap simultaneous git/fs work (fork-bomb guard)
|
||||
const GIT_STATUS_MAX_BUFFER = 1024 * 1024 // truncate huge porcelain output
|
||||
const MAX_PROJECTS = 200 // sane cap on the returned list
|
||||
const SKIP_DIR_NAMES = new Set(['node_modules'])
|
||||
|
||||
// ── parse (pure, unit-tested) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a `.git/HEAD` file. `ref: refs/heads/<branch>` → '<branch>' (slashes
|
||||
* kept); a bare 40-hex sha (detached HEAD) or any junk → null.
|
||||
*/
|
||||
export function parseGitHead(headText: string): string | null {
|
||||
const match = /^ref:\s+refs\/heads\/(.+)$/.exec(headText.trim())
|
||||
if (match === null) return null
|
||||
const branch = match[1]?.trim()
|
||||
return branch !== undefined && branch !== '' ? branch : null
|
||||
}
|
||||
|
||||
// ── small async utilities ───────────────────────────────────────────────────────
|
||||
|
||||
/** Run `fn` over `items` with at most `limit` in flight; preserves order. */
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array<R>(items.length)
|
||||
let cursor = 0
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const idx = cursor
|
||||
cursor += 1
|
||||
if (idx >= items.length) return
|
||||
const item = items[idx]
|
||||
if (item === undefined) continue
|
||||
results[idx] = await fn(item)
|
||||
}
|
||||
}
|
||||
const poolSize = Math.min(Math.max(limit, 1), items.length)
|
||||
await Promise.all(Array.from({ length: poolSize }, () => worker()))
|
||||
return results
|
||||
}
|
||||
|
||||
/** Skip dotfiles/dotdirs (incl. .git) and known-heavy dirs when descending. */
|
||||
function shouldSkipDir(name: string): boolean {
|
||||
return name.startsWith('.') || SKIP_DIR_NAMES.has(name)
|
||||
}
|
||||
|
||||
// ── per-repo metadata (best-effort) ─────────────────────────────────────────────
|
||||
|
||||
/** Read the current branch from `<repo>/.git/HEAD`; undefined if unreadable. */
|
||||
async function readBranch(repoPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8')
|
||||
return parseGitHead(head) ?? undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** `git status --porcelain` → dirty?; undefined on error/timeout/non-repo. */
|
||||
async function readDirty(repoPath: string): Promise<boolean | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
cwd: repoPath,
|
||||
timeout: GIT_STATUS_TIMEOUT_MS,
|
||||
maxBuffer: GIT_STATUS_MAX_BUFFER,
|
||||
})
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff `<dir>/.git` exists (file or directory). */
|
||||
async function hasGitEntry(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(path.join(dir, '.git'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface MakeProjectArgs {
|
||||
readonly path: string
|
||||
readonly isGit: boolean
|
||||
readonly branch?: string
|
||||
readonly dirty?: boolean
|
||||
readonly lastActiveMs?: number
|
||||
}
|
||||
|
||||
function makeProject(args: MakeProjectArgs): ProjectInfo {
|
||||
return {
|
||||
name: path.basename(args.path),
|
||||
path: args.path,
|
||||
isGit: args.isGit,
|
||||
branch: args.branch,
|
||||
dirty: args.dirty,
|
||||
lastActiveMs: args.lastActiveMs,
|
||||
sessions: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ── step 1: breadth-first repo scan ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* BFS each root to `maxDepth`. A directory containing a `.git` entry is recorded
|
||||
* as a repo and NOT descended into. Unreadable dirs are skipped (best-effort);
|
||||
* node_modules / dotdirs / symlinks are never followed.
|
||||
*/
|
||||
async function scanRepos(roots: readonly string[], maxDepth: number): Promise<string[]> {
|
||||
const found: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const queue: Array<{ dir: string; depth: number }> = roots.map((r) => ({
|
||||
dir: path.resolve(r),
|
||||
depth: 0,
|
||||
}))
|
||||
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift()
|
||||
if (next === undefined) break
|
||||
const { dir, depth } = next
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue // unreadable dir → skip
|
||||
}
|
||||
|
||||
if (entries.some((e) => e.name === '.git')) {
|
||||
found.push(dir)
|
||||
continue // don't descend into a repo
|
||||
}
|
||||
if (depth >= maxDepth) continue
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) continue // never follow symlinks
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldSkipDir(entry.name)) continue
|
||||
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// ── step 3: merge recently-used cwds from history ───────────────────────────────
|
||||
|
||||
/** Newest mtime per distinct (non-empty) cwd seen in ~/.claude/projects. */
|
||||
async function lastActiveByCwd(): Promise<Map<string, number>> {
|
||||
let sessions
|
||||
try {
|
||||
sessions = await listSessions()
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
const byCwd = new Map<string, number>()
|
||||
for (const s of sessions) {
|
||||
if (s.cwd === '') continue
|
||||
const prev = byCwd.get(s.cwd)
|
||||
if (prev === undefined || s.mtimeMs > prev) byCwd.set(s.cwd, s.mtimeMs)
|
||||
}
|
||||
return byCwd
|
||||
}
|
||||
|
||||
async function mergeHistory(repos: ProjectInfo[]): Promise<ProjectInfo[]> {
|
||||
const byCwd = await lastActiveByCwd()
|
||||
const knownPaths = new Set(repos.map((p) => p.path))
|
||||
|
||||
// Stamp discovered repos that also appear in history.
|
||||
const stamped = repos.map((p) => {
|
||||
const ms = byCwd.get(p.path)
|
||||
return ms === undefined ? p : { ...p, lastActiveMs: ms }
|
||||
})
|
||||
|
||||
// Add history cwds not already in discovered repos — processed concurrently.
|
||||
const historyEntries = [...byCwd.entries()].filter(([cwd]) => !knownPaths.has(cwd))
|
||||
const extras = await mapWithConcurrency(
|
||||
historyEntries,
|
||||
GIT_CONCURRENCY,
|
||||
async ([cwd, ms]) => {
|
||||
const isGit = await hasGitEntry(cwd)
|
||||
const branch = isGit ? await readBranch(cwd) : undefined
|
||||
return makeProject({ path: cwd, isGit, branch, lastActiveMs: ms })
|
||||
},
|
||||
)
|
||||
|
||||
return [...stamped, ...extras]
|
||||
}
|
||||
|
||||
// ── steps 1–3: cached disk discovery ─────────────────────────────────────────────
|
||||
|
||||
interface DiscoverCacheEntry {
|
||||
readonly key: string
|
||||
readonly expiresAt: number
|
||||
readonly projects: ProjectInfo[]
|
||||
}
|
||||
|
||||
let discoverCache: DiscoverCacheEntry | null = null
|
||||
/** Shared in-flight promise so concurrent cache-miss callers join one discovery run. */
|
||||
let inflightDiscovery: { key: string; promise: Promise<ProjectInfo[]> } | null = null
|
||||
|
||||
function cacheKey(cfg: Config): string {
|
||||
return JSON.stringify({
|
||||
roots: cfg.projectRoots,
|
||||
depth: cfg.projectScanDepth,
|
||||
dirty: cfg.projectDirtyCheck,
|
||||
})
|
||||
}
|
||||
|
||||
function dedupByPath(projects: readonly ProjectInfo[]): ProjectInfo[] {
|
||||
const seen = new Set<string>()
|
||||
const out: ProjectInfo[] = []
|
||||
for (const p of projects) {
|
||||
if (seen.has(p.path)) continue
|
||||
seen.add(p.path)
|
||||
out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
|
||||
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
|
||||
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
|
||||
const branch = await readBranch(repoPath)
|
||||
const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined
|
||||
return makeProject({ path: repoPath, isGit: true, branch, dirty })
|
||||
})
|
||||
const merged = await mergeHistory(repos)
|
||||
return dedupByPath(merged)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached steps 1–3, keyed by (roots+depth+dirtyCheck) with cfg.projectScanTtlMs.
|
||||
* Concurrent cache-miss callers share a single in-flight promise (cache-stampede guard).
|
||||
*/
|
||||
async function discoverProjects(cfg: Config): Promise<ProjectInfo[]> {
|
||||
const now = Date.now()
|
||||
const key = cacheKey(cfg)
|
||||
if (discoverCache !== null && discoverCache.key === key && discoverCache.expiresAt > now) {
|
||||
return discoverCache.projects
|
||||
}
|
||||
// Deduplicate concurrent cache-miss calls onto one discovery run.
|
||||
if (inflightDiscovery !== null && inflightDiscovery.key === key) {
|
||||
return inflightDiscovery.promise
|
||||
}
|
||||
const promise = runDiscovery(cfg)
|
||||
.then((projects) => {
|
||||
discoverCache = { key, expiresAt: Date.now() + cfg.projectScanTtlMs, projects }
|
||||
inflightDiscovery = null
|
||||
return projects
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
inflightDiscovery = null
|
||||
throw e
|
||||
})
|
||||
inflightDiscovery = { key, promise }
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Test-only: drop the discovery cache and any in-flight run so each test sees a fresh scan. */
|
||||
export function _clearProjectCache(): void {
|
||||
discoverCache = null
|
||||
inflightDiscovery = null
|
||||
}
|
||||
|
||||
// ── step 4: fresh live-session merge + sort ──────────────────────────────────────
|
||||
|
||||
function lastSegment(cwd: string | null): string | undefined {
|
||||
if (cwd === null || cwd === '') return undefined
|
||||
return cwd.split(path.sep).filter(Boolean).pop()
|
||||
}
|
||||
|
||||
/** A session belongs to a project when its spawn cwd is the path or under it. */
|
||||
function belongsTo(cwd: string | null, projectPath: string): boolean {
|
||||
if (cwd === null) return false
|
||||
if (cwd === projectPath) return true
|
||||
return cwd.startsWith(projectPath + path.sep)
|
||||
}
|
||||
|
||||
function toSessionRef(s: LiveSessionInfo): ProjectSessionRef {
|
||||
return {
|
||||
id: s.id,
|
||||
title: lastSegment(s.cwd),
|
||||
status: s.status,
|
||||
clientCount: s.clientCount,
|
||||
createdAt: s.createdAt,
|
||||
exited: s.exited,
|
||||
}
|
||||
}
|
||||
|
||||
function matchSessions(projectPath: string, live: readonly LiveSessionInfo[]): ProjectSessionRef[] {
|
||||
return live.filter((s) => belongsTo(s.cwd, projectPath)).map(toSessionRef)
|
||||
}
|
||||
|
||||
function sortProjects(projects: readonly ProjectInfo[]): ProjectInfo[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
if (a.lastActiveMs !== b.lastActiveMs) {
|
||||
if (a.lastActiveMs === undefined) return 1 // undefined sorts last
|
||||
if (b.lastActiveMs === undefined) return -1
|
||||
return b.lastActiveMs - a.lastActiveMs // most-recent first
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the host's projects and attach the currently-running sessions.
|
||||
* `liveSessions` is INJECTED (e.g. `manager.list()`) so this stays pure-testable.
|
||||
* Disk discovery is cached; the session merge always runs fresh.
|
||||
*/
|
||||
export async function buildProjects(
|
||||
cfg: Config,
|
||||
liveSessions: readonly LiveSessionInfo[],
|
||||
): Promise<ProjectInfo[]> {
|
||||
const base = await discoverProjects(cfg)
|
||||
const withSessions = base.map((p) => ({
|
||||
...p,
|
||||
sessions: matchSessions(p.path, liveSessions),
|
||||
}))
|
||||
return sortProjects(withSessions).slice(0, MAX_PROJECTS)
|
||||
}
|
||||
@@ -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<void> } {
|
||||
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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
144
test/integration/projects-endpoint.test.ts
Normal file
144
test/integration/projects-endpoint.test.ts
Normal file
@@ -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<number> {
|
||||
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/<repoName>/`. Returns the repo path. */
|
||||
async function makeFakeGitRepo(parentDir: string, repoName: string): Promise<string> {
|
||||
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<void> }
|
||||
|
||||
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<void>((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)
|
||||
})
|
||||
})
|
||||
273
test/projects-panel.test.ts
Normal file
273
test/projects-panel.test.ts
Normal file
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
349
test/projects.test.ts
Normal file
349
test/projects.test.ts
Normal file
@@ -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<HistorySession[]> => [])
|
||||
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>): 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>): 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<void> {
|
||||
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<boolean> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user