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)。
|
- **全部 6 任务完成并验证**(P1–P6,多 agent 编排)。**398 测试全绿**(自 341 +57)· 双 typecheck 干净 · `build:web` OK · 覆盖率 90.98/81.95/89.28/93.16(阈值 80×4 通过)· **真浏览器 smoke 通过**。
|
||||||
- **主题已 ship**: `public/style.css` 靛蓝 `#7c8cff` → **Amber 琥珀金 `#e3a64a`**;新增 `--on-accent`;暖灰底;`--green/--amber/--red` 状态色保留;keybar 残留靛蓝已清。
|
- **编排方式**: 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 重跑成功。**
|
||||||
- **地基已落(编译绿,未提交)**:
|
- **交付**:
|
||||||
- ✅ **T-PM1 契约** `src/types.ts`: 新增 `ProjectInfo` / `ProjectSessionRef`(复用 `ClaudeStatus`) + Config 4 字段(`projectRoots/projectScanDepth/projectScanTtlMs/projectDirtyCheck`)。
|
- **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 去重**。
|
||||||
- ✅ **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` 通过。
|
- **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)。
|
||||||
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)。
|
- **P6** `public/tabs.ts`: `openProject`(同名 `#n` 后缀、`addEntry(null,label,cwd,'claude\r')`)+ `countOpenWithTitlePrefix` + 首页 **Sessions↔Projects 段控**(`updateHomeView`);7 新测试。
|
||||||
2. **W2 并行 2**: **T-PM4** `src/server.ts` 加 `GET /projects`(只读,无 Origin 守卫,喂 `manager.list()`);**T-PM6** `public/tabs.ts` `openProject`(同名加 `#n`,复用 `addEntry`)+ 挂 `mountProjects` + 首页 Sessions↔Projects 段控。
|
- **Review 12 findings → 4 HIGH 全确认并修复**: ① **A3 关键**:`openProject`/`newTabForResume` 缺回车 `\r`(claude 不自动执行)→ 补 `\r`;② 段控被定位面板盖住 → 面板 `inset:44px 0 0 0`;③ `mergeHistory` 串行 I/O → `mapWithConcurrency`;④ discover 并发缓存击穿 → in-flight promise 去重。
|
||||||
3. **W3**: `module-reviewer` 验收 A1–A6(report-only)。
|
- **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 仍打单文件,留作后续拆分。
|
||||||
- 关键接口约定: `buildProjects(cfg, liveSessions): Promise<ProjectInfo[]>`(注入 liveSessions 便于纯测)。
|
- **真浏览器 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 报错。
|
||||||
- **注意**: 当前工作树有未提交改动(types.ts/config.ts/style.css/docs)。建议重启后先 `git status` 核对,按规范建 `v0.6-projects` 分支再继续。
|
- **待办**: 真机/手机验收(同历史 F4/F7 遗留);可选 server.ts 抽 routes;`style.css` 拆分。
|
||||||
|
|
||||||
- **当前阶段**: **v0.3 全部完成**(H1–H4 + M3/M6/M7 已并入 main;**O2 历史浏览** 在分支 `o2-history`,待合并)。
|
- **当前阶段**: **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(靠标签点)。
|
- ✅ 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>
|
- **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 脚手架 + 一次性装齐依赖
|
### 2026-06-16 · T1 脚手架 + 一次性装齐依赖
|
||||||
|
|
||||||
- **状态**: `[x]` DONE
|
- **状态**: `[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 (home session chooser, shown when no tab is open) ──── */
|
||||||
.launcher {
|
.launcher {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 44px 0 0 0; /* top offset reserves space for .home-seg (height ~32px + gap) */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 22px 18px 40px;
|
padding: 22px 18px 40px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -872,3 +872,272 @@ body {
|
|||||||
padding: 30px 0;
|
padding: 30px 0;
|
||||||
text-align: center;
|
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
|
* Closing a tab only disconnects its WS (a *detach*); the server PTY keeps
|
||||||
* running and is reclaimed by IDLE_TTL.
|
* 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 { TerminalSession } from './terminal-session.js'
|
||||||
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
||||||
import { mountLauncher, type Launcher } from './launcher.js'
|
import { mountLauncher, type Launcher } from './launcher.js'
|
||||||
|
import { mountProjects, type ProjectsPanel } from './projects.js'
|
||||||
|
|
||||||
const TABS_KEY = 'web-terminal:tabs'
|
const TABS_KEY = 'web-terminal:tabs'
|
||||||
const ACTIVE_KEY = 'web-terminal:active'
|
const ACTIVE_KEY = 'web-terminal:active'
|
||||||
|
const HOME_VIEW_KEY = 'web-terminal:home-view'
|
||||||
|
|
||||||
|
type HomeView = 'sessions' | 'projects'
|
||||||
|
|
||||||
interface TabEntry {
|
interface TabEntry {
|
||||||
session: TerminalSession
|
session: TerminalSession
|
||||||
@@ -47,7 +56,9 @@ export class TabApp {
|
|||||||
private readonly tabBar: HTMLElement
|
private readonly tabBar: HTMLElement
|
||||||
private readonly approvalBar: HTMLDivElement
|
private readonly approvalBar: HTMLDivElement
|
||||||
private readonly launcher: Launcher
|
private readonly launcher: Launcher
|
||||||
private launcherVisible = false
|
private readonly projects: ProjectsPanel
|
||||||
|
private homeView: HomeView = 'sessions'
|
||||||
|
private readonly segControl: HTMLElement
|
||||||
|
|
||||||
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
||||||
this.paneHost = paneHost
|
this.paneHost = paneHost
|
||||||
@@ -56,25 +67,100 @@ export class TabApp {
|
|||||||
this.approvalBar.id = 'approvalbar'
|
this.approvalBar.id = 'approvalbar'
|
||||||
this.approvalBar.style.display = 'none'
|
this.approvalBar.style.display = 'none'
|
||||||
document.body.appendChild(this.approvalBar)
|
document.body.appendChild(this.approvalBar)
|
||||||
|
|
||||||
this.launcher = mountLauncher(this.paneHost, {
|
this.launcher = mountLauncher(this.paneHost, {
|
||||||
onOpen: (id) => this.openSession(id),
|
onOpen: (id) => this.openSession(id),
|
||||||
onNew: () => this.newTab(),
|
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
|
// Load persisted home view choice.
|
||||||
// server-side, so opening one replays its full scrollback.
|
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.rebuild()
|
||||||
this.updateLauncher()
|
this.updateHomeView()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show the launcher (session chooser) iff no tab is open. */
|
/* ── Home-view visibility ────────────────────────────────────────── */
|
||||||
private updateLauncher(): void {
|
|
||||||
|
/**
|
||||||
|
* 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
|
const empty = this.tabs.length === 0
|
||||||
if (empty === this.launcherVisible) return
|
|
||||||
this.launcherVisible = empty
|
// Sync active-class on segmented control buttons.
|
||||||
this.launcher.setVisible(empty)
|
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). */
|
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
|
||||||
private updateApprovalBar(): void {
|
private updateApprovalBar(): void {
|
||||||
const session = this.tabs[this.activeIndex]?.session
|
const session = this.tabs[this.activeIndex]?.session
|
||||||
@@ -108,7 +194,7 @@ export class TabApp {
|
|||||||
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
|
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── persistence ─────────────────────────────────────────────── */
|
/* ── persistence ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
private persist(): void {
|
private persist(): void {
|
||||||
try {
|
try {
|
||||||
@@ -138,7 +224,7 @@ export class TabApp {
|
|||||||
this.activate(this.tabs.length - 1)
|
this.activate(this.tabs.length - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── tab lifecycle ───────────────────────────────────────────── */
|
/* ── tab lifecycle ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
private addEntry(
|
private addEntry(
|
||||||
sessionId: string | null,
|
sessionId: string | null,
|
||||||
@@ -193,12 +279,34 @@ export class TabApp {
|
|||||||
|
|
||||||
/** O2: open a new tab in `cwd` and run `claude --resume <id>`. */
|
/** O2: open a new tab in `cwd` and run `claude --resume <id>`. */
|
||||||
newTabForResume(cwd: string, sessionId: string): void {
|
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.persist()
|
||||||
this.rebuild()
|
this.rebuild()
|
||||||
this.activate(this.tabs.length - 1)
|
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 {
|
activate(i: number): void {
|
||||||
if (i < 0 || i >= this.tabs.length) return
|
if (i < 0 || i >= this.tabs.length) return
|
||||||
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
||||||
@@ -217,10 +325,10 @@ export class TabApp {
|
|||||||
entry?.session.dispose()
|
entry?.session.dispose()
|
||||||
if (this.editingIndex === i) this.editingIndex = -1
|
if (this.editingIndex === i) this.editingIndex = -1
|
||||||
if (this.tabs.length === 0) {
|
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.activeIndex = -1
|
||||||
this.persist()
|
this.persist()
|
||||||
this.rebuild() // updateLauncher() (in rebuild) shows the chooser
|
this.rebuild() // updateHomeView() (in rebuild) shows the chooser
|
||||||
this.updateApprovalBar()
|
this.updateApprovalBar()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -323,7 +431,7 @@ export class TabApp {
|
|||||||
new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
|
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. */
|
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */
|
||||||
private refreshTab(entry: TabEntry): void {
|
private refreshTab(entry: TabEntry): void {
|
||||||
@@ -453,7 +561,7 @@ export class TabApp {
|
|||||||
add.addEventListener('click', () => this.newTab())
|
add.addEventListener('click', () => this.newTab())
|
||||||
this.tabBar.appendChild(add)
|
this.tabBar.appendChild(add)
|
||||||
|
|
||||||
// Show/hide the launcher based on whether any tab is open.
|
// Show/hide the home screen based on whether any tab is open.
|
||||||
this.updateLauncher()
|
this.updateHomeView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
import type { Config, EnvLike } from './types.js'
|
import type { Config, EnvLike } from './types.js'
|
||||||
import { tmuxAvailable } from './session/tmux.js'
|
import { tmuxAvailable } from './session/tmux.js'
|
||||||
|
|
||||||
@@ -70,6 +71,16 @@ function parseProjectRoots(raw: string | undefined, homeDir: string): readonly s
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s) => s !== '')
|
.filter((s) => s !== '')
|
||||||
.map((s) => (s === '~' ? homeDir : s.startsWith('~/') ? homeDir + s.slice(1) : 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])
|
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 { isOriginAllowed } from './http/origin.js'
|
||||||
import { parseHookEvent } from './http/hook.js'
|
import { parseHookEvent } from './http/hook.js'
|
||||||
import { listSessions } from './http/history.js'
|
import { listSessions } from './http/history.js'
|
||||||
|
import { buildProjects } from './http/projects.js'
|
||||||
import { createSessionManager } from './session/manager.js'
|
import { createSessionManager } from './session/manager.js'
|
||||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||||
import type { Config } from './types.js'
|
import type { Config } from './types.js'
|
||||||
@@ -158,6 +159,16 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
res.json(manager.list())
|
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
|
// 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.
|
// can render a live read-only thumbnail of its current screen. No client/attach.
|
||||||
app.get('/live-sessions/:id/preview', (req, res) => {
|
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)
|
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.
|
* test/tabs.test.ts — frontend TabApp unit tests.
|
||||||
*
|
*
|
||||||
* Mocks TerminalSession + the launcher so we test TabApp's tab lifecycle in
|
* Mocks TerminalSession + the launcher + projects panel so we test TabApp's
|
||||||
* isolation: the v0.5 "close last tab → launcher" invariant and that addEntry
|
* tab lifecycle in isolation: the v0.5 "close last tab → launcher" invariant,
|
||||||
* builds a real (non-null) session before pushing the entry (Phase 1#5 — the
|
* that addEntry builds a real (non-null) session before pushing the entry
|
||||||
* `null as unknown as TerminalSession` hole is gone).
|
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
@@ -19,6 +19,8 @@ class FakeTerminalSession {
|
|||||||
claudeStatus = 'unknown'
|
claudeStatus = 'unknown'
|
||||||
cwd: string | null = null
|
cwd: string | null = null
|
||||||
pendingApproval = false
|
pendingApproval = false
|
||||||
|
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
|
||||||
|
initialInput: string | undefined = undefined
|
||||||
connect = vi.fn()
|
connect = vi.fn()
|
||||||
dispose = vi.fn()
|
dispose = vi.fn()
|
||||||
show = vi.fn()
|
show = vi.fn()
|
||||||
@@ -41,13 +43,16 @@ class FakeTerminalSession {
|
|||||||
static instances: FakeTerminalSession[] = []
|
static instances: FakeTerminalSession[] = []
|
||||||
constructor(opts: {
|
constructor(opts: {
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
|
initialInput?: string
|
||||||
onClaudeStatus?: (s: string, d?: string) => void
|
onClaudeStatus?: (s: string, d?: string) => void
|
||||||
onStatus?: (s: string) => void
|
onStatus?: (s: string) => void
|
||||||
onActivity?: () => void
|
onActivity?: () => void
|
||||||
onTitle?: (t: string) => void
|
onTitle?: (t: string) => void
|
||||||
|
[key: string]: unknown
|
||||||
}) {
|
}) {
|
||||||
constructed += 1
|
constructed += 1
|
||||||
this.id = opts.sessionId
|
this.id = opts.sessionId
|
||||||
|
this.initialInput = opts.initialInput
|
||||||
this.el = document.createElement('div')
|
this.el = document.createElement('div')
|
||||||
this.cbs = opts
|
this.cbs = opts
|
||||||
FakeTerminalSession.instances.push(this)
|
FakeTerminalSession.instances.push(this)
|
||||||
@@ -66,6 +71,16 @@ const mountLauncher = vi.fn(() => ({
|
|||||||
}))
|
}))
|
||||||
vi.mock('../public/launcher.js', () => ({ mountLauncher }))
|
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.
|
// settings.js is light but imports nothing heavy; let it load for real.
|
||||||
|
|
||||||
const { TabApp } = await import('../public/tabs.js')
|
const { TabApp } = await import('../public/tabs.js')
|
||||||
@@ -81,6 +96,7 @@ beforeEach(() => {
|
|||||||
constructed = 0
|
constructed = 0
|
||||||
FakeTerminalSession.instances = []
|
FakeTerminalSession.instances = []
|
||||||
launcherVisible.value = false
|
launcherVisible.value = false
|
||||||
|
projectsVisible.value = false
|
||||||
document.body.replaceChildren()
|
document.body.replaceChildren()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
@@ -201,6 +217,22 @@ describe('TabApp — multi-tab lifecycle', () => {
|
|||||||
expect(app.snapshot()).toHaveLength(1)
|
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', () => {
|
it('snapshot reflects per-tab connection + claude status fields', () => {
|
||||||
const { paneHost, tabBar } = makeHosts()
|
const { paneHost, tabBar } = makeHosts()
|
||||||
const app = new TabApp(paneHost, tabBar)
|
const app = new TabApp(paneHost, tabBar)
|
||||||
@@ -346,3 +378,88 @@ describe('TabApp — DOM interactions on the tab bar', () => {
|
|||||||
expect(app.snapshot()).toHaveLength(2)
|
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