diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 3417a3f..5bb151f 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -146,6 +146,18 @@ - **commit**: ======================================================= --> +### 2026-06-30 · v0.6 项目详情页 — branch/worktree + 详细 active session + +- **状态**: `[x]` DONE。**452 测试全绿**(+21)· 双 typecheck 干净 · `build:web` OK · 覆盖率 90.16/81.32(`worktrees.ts` 100% / `projects.ts` 93.8%)· 真端到端(curl + 真浏览器)验证通过。 +- **需求**(用户): 每个 project 的**详情页**——详细显示具体 active 的 session,以及具体的 **branch 或 worktree**。 +- **设计**: 点项目卡的**名字**进入应用内详情视图(`renderProjectDetail`,在 Projects 面板内切换,带"← All projects"返回)。分区:① 头部(名/路径/branch chip/dirty)② **Branch / Worktrees**(`git worktree list` 每条:branch 或 `detached @ head` + main/current/locked/prunable 标签 + 路径;单 worktree 即显示分支)③ **Active sessions(N)**(每条:状态点 + 标题 + `状态·👁设备数·started 多久前` + **Open** + **Kill ✕**)④ **Start** 启动器行(复用 Claude/Codex/Code)。再进 Projects 总是回到网格(不残留详情)。 +- **后端**: 新增 `src/http/worktrees.ts`(`parseWorktrees`(纯)+ `listWorktrees`,`git worktree list --porcelain`,execFile 无 shell,2s 超时,best-effort→[]);`projects.ts` 加 `buildProjectDetail(cfg, path, liveSessions)`(校验绝对存在目录→否则 null;复用 readBranch/readDirty/hasGitEntry/matchSessions;**不缓存**,详情按需要新鲜)。`src/server.ts` `GET /projects/detail?path=`(只读、无 Origin 守卫,同 /projects;path 缺→400、非法/不存在→404)。types 加 `WorktreeInfo`/`ProjectDetail`。 +- **前端**(`public/projects.ts` + style): `fetchProjectDetail`/`killSession`;`makeProjectCard` 加可选 `onOpenDetail`(名字变可点链接 `.proj-name-link`);`mountProjects` 加 grid↔detail 视图切换 + 自动刷新路由(详情每 5s 刷新);Kill 走 `DELETE /live-sessions/:id`(同源带 Origin)。详情视图样式 `.proj-detail*`/`.proj-wt-*`/`.proj-dsession*`。session meta 在 status=unknown 时省略状态点(不显示 `· ·`)。 +- **测试**(+21): `test/worktrees.test.ts`(parse:单/多/detached/locked/prunable/无尾空行/空);`projects.test.ts` buildProjectDetail(相对/不存在/文件→null、非 git 目录、假 repo 读 branch、session 按 cwd 前缀归并、**真 git init worktree**(realpath 规避 /var symlink));`projects-panel.test.ts` renderProjectDetail(null→not found+Back、头部、worktree 行+current tag、detached、session Open/Kill 接线+忽略 exited、空态+启动器、非 git)+ 名字链接。 +- **验证**: `npx tsc`(双)干净;`npx vitest run --coverage` 452/452(≥80×4);真浏览器:开 web-terminal 的 Claude session→进详情→显示 branch `v0.6-projects`(MAIN/CURRENT)+ Active sessions(1)(web-terminal,👁1,Open/✕)+ Start 启动器 + 返回。curl `/projects/detail` 返回 worktree(main/current/head)+ 缺/相对 path→400/404。 +- **安全**: `/projects/detail` 只读(git branch/status/worktree-list,execFile 无 shell + 超时),path 校验绝对存在目录;与 /projects 同威胁模型(不新增可变更面)。 +- **commit**: (本次提交) + ### 2026-06-30 · v0.6 项目卡启动器 — Claude · Codex · VS Code logo 按钮 - **状态**: `[x]` DONE。覆盖率 90.6/81.48/88.71/92.71(≥80×4)· 双 typecheck 干净 · `build:web` OK · 真浏览器 + 真 VS Code 端到端验证通过。 diff --git a/public/projects.ts b/public/projects.ts index 8bf2857..4a2226c 100644 --- a/public/projects.ts +++ b/public/projects.ts @@ -13,8 +13,14 @@ * stopped + cleaned up on hide. */ -import type { ProjectInfo, ProjectSessionRef, ClaudeStatus } from '../src/types.js' -import { el, relTime } from './preview-grid.js' +import type { + ProjectInfo, + ProjectSessionRef, + ClaudeStatus, + ProjectDetail, + WorktreeInfo, +} from '../src/types.js' +import { el, relTime, statusText } from './preview-grid.js' import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js' /* ── Constants ───────────────────────────────────────────────────────────── */ @@ -137,6 +143,26 @@ async function fetchProjects(): Promise { } } +/** Fetch one project's detail (branch/worktrees + sessions). null on any error. */ +async function fetchProjectDetail(projectPath: string): Promise { + try { + const res = await fetch(`/projects/detail?path=${encodeURIComponent(projectPath)}`) + if (!res.ok) return null + return (await res.json()) as ProjectDetail + } catch { + return null + } +} + +/** Kill a running session (manage-page DELETE; same-origin → Origin guard passes). */ +async function killSession(id: string): Promise { + try { + await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) + } catch { + // best-effort — the refresh that follows will reflect the real state + } +} + /* ── Status helpers ──────────────────────────────────────────────────────── */ function sessionDotClass(status: ClaudeStatus): string { @@ -256,6 +282,7 @@ export function makeProjectCard( favs: ReadonlySet, hooks: ProjectsHooks, onToggleFav: (path: string) => void, + onOpenDetail?: (path: string) => void, ): HTMLElement { const card = el('div', 'proj-card') @@ -272,6 +299,19 @@ export function makeProjectCard( }) const name = el('span', 'proj-name', project.name) + if (onOpenDetail) { + name.classList.add('proj-name-link') + name.setAttribute('role', 'button') + name.tabIndex = 0 + name.title = `View ${project.name} details` + name.addEventListener('click', () => onOpenDetail(project.path)) + name.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenDetail(project.path) + } + }) + } header.append(favBtn, name) @@ -310,6 +350,112 @@ export function makeProjectCard( return card } +/* ── Detail view (branch / worktrees + active sessions) ─────────────────────── */ + +/** One worktree row: branch (or detached@head) + main/current/locked tags + path. */ +function makeWorktreeRow(wt: WorktreeInfo): HTMLElement { + const row = el('div', 'proj-wt-row') + const label = wt.branch ?? (wt.head ? `detached @ ${wt.head}` : 'detached') + row.append(el('span', 'proj-wt-branch', label)) + if (wt.isMain) row.append(el('span', 'proj-wt-tag', 'main')) + if (wt.isCurrent) row.append(el('span', 'proj-wt-tag proj-wt-tag-current', 'current')) + if (wt.locked) row.append(el('span', 'proj-wt-tag', 'locked')) + if (wt.prunable) row.append(el('span', 'proj-wt-tag', 'prunable')) + row.append(el('span', 'proj-wt-path', wt.path)) + return row +} + +/** One detailed session row: status + title + metadata + Open / Kill actions. */ +function makeDetailSessionRow( + s: ProjectSessionRef, + onEnter: (id: string) => void, + onKill: (id: string) => void, +): HTMLElement { + const row = el('div', 'proj-dsession') + const dot = el('span', sessionDotClass(s.status)) + const main = el('div', 'proj-dsession-main') + main.append(el('span', 'proj-dsession-title', s.title ?? 'session')) + const parts: string[] = [] + if (s.status !== 'unknown') parts.push(statusText(s.status)) + parts.push(`👁 ${s.clientCount}`) + parts.push(`started ${relTime(s.createdAt)} ago`) + main.append(el('span', 'proj-dsession-meta', parts.join(' · '))) + const open = el('button', 'proj-dsession-open', 'Open') + open.addEventListener('click', () => onEnter(s.id)) + const kill = el('button', 'proj-dsession-kill', '✕') + kill.title = 'Kill this session' + kill.setAttribute('aria-label', 'Kill session') + kill.addEventListener('click', () => onKill(s.id)) + row.append(dot, main, open, kill) + return row +} + +interface DetailCallbacks { + onBack: () => void + onKill: (id: string) => void +} + +/** Render the project detail view (exported for unit tests). */ +export function renderProjectDetail( + detail: ProjectDetail | null, + hooks: ProjectsHooks, + cb: DetailCallbacks, +): HTMLElement { + const root = el('div', 'proj-detail-inner') + + const back = el('button', 'proj-back', '← All projects') + back.addEventListener('click', () => cb.onBack()) + root.append(back) + + if (detail === null) { + root.append(el('div', 'proj-empty', 'Project not found.')) + return root + } + + // Header: name + branch + dirty + path + const head = el('div', 'proj-detail-head') + head.append(el('h2', 'proj-detail-name', detail.name)) + if (detail.branch) head.append(el('span', 'proj-branch', detail.branch)) + if (detail.dirty === true) { + const d = el('span', 'proj-dirty', '●') + d.title = 'Uncommitted changes' + head.append(d) + } + root.append(head) + root.append(el('div', 'proj-detail-path', detail.path)) + + // Branch / worktrees + root.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch')) + if (!detail.isGit) { + root.append(el('div', 'proj-empty', 'Not a git repository.')) + } else if (detail.worktrees.length === 0) { + root.append( + el('div', 'proj-empty', detail.branch ? `On branch ${detail.branch}` : 'No worktree info.'), + ) + } else { + const list = el('div', 'proj-wt-list') + for (const wt of detail.worktrees) list.append(makeWorktreeRow(wt)) + root.append(list) + } + + // Active sessions + const running = detail.sessions.filter((s) => !s.exited) + root.append(el('div', 'proj-section-title', `Active sessions (${running.length})`)) + if (running.length === 0) { + root.append(el('div', 'proj-empty', 'No running sessions — start one below.')) + } else { + const list = el('div', 'proj-detail-sessions') + for (const s of running) list.append(makeDetailSessionRow(s, hooks.onEnterSession, cb.onKill)) + root.append(list) + } + + // Launchers + root.append(el('div', 'proj-section-title', 'Start')) + root.append(makeLauncherRow(detail, hooks)) + + return root +} + /* ── Mount ───────────────────────────────────────────────────────────────── */ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): ProjectsPanel { @@ -330,9 +476,36 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects const grid = el('div', 'proj-grid') root.append(grid) + const detailEl = el('div', 'proj-detail') + detailEl.style.display = 'none' + root.append(detailEl) + let allProjects: ProjectInfo[] = [] let favs: Set = getFavs() let timer: ReturnType | null = null + let view: 'grid' | 'detail' = 'grid' + let detailPath: string | null = null + + function applyView(): void { + const isGrid = view === 'grid' + searchWrap.style.display = isGrid ? '' : 'none' + grid.style.display = isGrid ? '' : 'none' + detailEl.style.display = isGrid ? 'none' : 'block' + } + + function openDetail(p: string): void { + view = 'detail' + detailPath = p + applyView() + void refresh() + } + + function closeDetail(): void { + view = 'grid' + detailPath = null + applyView() + void refresh() + } function renderGrid(): void { const filtered = filterProjects(allProjects, searchInput.value) @@ -351,16 +524,39 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects for (const p of sorted) { grid.append( - makeProjectCard(p, favs, hooks, (path) => { - favs = toggleFav(favs, path) - saveFavs(favs) - renderGrid() - }), + makeProjectCard( + p, + favs, + hooks, + (path) => { + favs = toggleFav(favs, path) + saveFavs(favs) + renderGrid() + }, + openDetail, + ), ) } } + function renderDetail(detail: ProjectDetail | null): void { + detailEl.replaceChildren( + renderProjectDetail(detail, hooks, { + onBack: closeDetail, + onKill: (id) => { + void killSession(id).then(() => { + if (view === 'detail') void refresh() + }) + }, + }), + ) + } + async function refresh(): Promise { + if (view === 'detail' && detailPath !== null) { + renderDetail(await fetchProjectDetail(detailPath)) + return + } allProjects = await fetchProjects() favs = getFavs() renderGrid() @@ -372,6 +568,10 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects setVisible(v: boolean): void { root.style.display = v ? 'block' : 'none' if (v) { + // Re-entering the chooser always lands on the grid (never a stale detail). + view = 'grid' + detailPath = null + applyView() void refresh() if (timer === null) { timer = setInterval(() => { diff --git a/public/style.css b/public/style.css index 24403c9..ced0b78 100644 --- a/public/style.css +++ b/public/style.css @@ -1156,6 +1156,160 @@ body { text-align: center; } +/* ── Project detail view ─────────────────────────────────────────── */ +.proj-name-link { + cursor: pointer; +} +.proj-name-link:hover { + text-decoration: underline; + text-underline-offset: 2px; +} +.proj-detail-inner { + max-width: 900px; + margin: 0 auto; +} +.proj-back { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + border-radius: 8px; + padding: 6px 12px; + font: inherit; + font-size: 13px; + cursor: pointer; + margin-bottom: 16px; + transition: background 0.12s ease, color 0.12s ease; +} +.proj-back:hover { + background: var(--surface-2); + color: var(--text); +} +.proj-detail-head { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.proj-detail-name { + margin: 0; + font-size: 22px; + font-weight: 700; + color: #fff; +} +.proj-detail-path { + margin: 4px 0 8px; + font-family: Menlo, Consolas, monospace; + font-size: 12px; + color: var(--text-faint); + word-break: break-all; +} +.proj-section-title { + margin: 22px 0 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); +} +/* Worktrees */ +.proj-wt-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.proj-wt-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; +} +.proj-wt-branch { + font-weight: 600; + color: var(--text); +} +.proj-wt-tag { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 7px; + border-radius: 999px; + background: var(--surface-3); + color: var(--text-dim); +} +.proj-wt-tag-current { + background: var(--accent); + color: var(--on-accent); +} +.proj-wt-path { + margin-left: auto; + font-family: Menlo, Consolas, monospace; + font-size: 11px; + color: var(--text-faint); + word-break: break-all; +} +/* Detailed session rows */ +.proj-detail-sessions { + display: flex; + flex-direction: column; + gap: 8px; +} +.proj-dsession { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; +} +.proj-dsession-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} +.proj-dsession-title { + font-weight: 600; + color: var(--text); +} +.proj-dsession-meta { + font-size: 12px; + color: var(--text-dim); +} +.proj-dsession-open { + background: var(--accent); + color: var(--on-accent); + border: none; + border-radius: 7px; + padding: 6px 14px; + font: inherit; + font-weight: 600; + cursor: pointer; +} +.proj-dsession-open:hover { + background: var(--accent-2); +} +.proj-dsession-kill { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + border-radius: 7px; + width: 30px; + height: 30px; + cursor: pointer; + font-size: 14px; +} +.proj-dsession-kill:hover { + background: var(--red, #e5484d); + border-color: var(--red, #e5484d); + color: #fff; +} + /* Empty state */ .proj-empty { color: var(--text-dim); diff --git a/src/http/projects.ts b/src/http/projects.ts index f78ec40..53ab01d 100644 --- a/src/http/projects.ts +++ b/src/http/projects.ts @@ -19,8 +19,15 @@ 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 type { + Config, + LiveSessionInfo, + ProjectInfo, + ProjectSessionRef, + ProjectDetail, +} from '../types.js' import { listSessions } from './history.js' +import { listWorktrees } from './worktrees.js' const execFileAsync = promisify(execFile) @@ -353,3 +360,42 @@ export async function buildProjects( })) return sortProjects(withSessions).slice(0, MAX_PROJECTS) } + +/** + * Detail for one project: branch + dirty + worktrees + its running sessions. + * `projectPath` must be an absolute, existing directory (else null — 404). Not + * cached: the detail view is opened on demand and wants fresh worktree/session + * state. Reads only (git branch/status/worktree-list); never throws. + */ +export async function buildProjectDetail( + cfg: Config, + projectPath: string, + liveSessions: readonly LiveSessionInfo[], +): Promise { + if (typeof projectPath !== 'string' || !path.isAbsolute(projectPath)) return null + + let stat + try { + stat = await fs.stat(projectPath) + } catch { + return null + } + if (!stat.isDirectory()) return null + + const isGit = await hasGitEntry(projectPath) + const [branch, dirty, worktrees] = await Promise.all([ + isGit ? readBranch(projectPath) : Promise.resolve(undefined), + isGit && cfg.projectDirtyCheck ? readDirty(projectPath) : Promise.resolve(undefined), + isGit ? listWorktrees(projectPath) : Promise.resolve([]), + ]) + + return { + name: path.basename(projectPath), + path: projectPath, + isGit, + branch, + dirty, + worktrees, + sessions: matchSessions(projectPath, liveSessions), + } +} diff --git a/src/http/worktrees.ts b/src/http/worktrees.ts new file mode 100644 index 0000000..a3f4798 --- /dev/null +++ b/src/http/worktrees.ts @@ -0,0 +1,73 @@ +/** + * src/http/worktrees.ts (v0.6 project detail) — list a repo's git worktrees. + * + * `git worktree list --porcelain` emits blank-line-separated records; the first + * record is the main worktree. Best-effort: a non-repo / git error yields []. + */ + +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { WorktreeInfo } from '../types.js' + +const execFileAsync = promisify(execFile) + +const WORKTREE_TIMEOUT_MS = 2000 +const WORKTREE_MAX_BUFFER = 1024 * 1024 + +/** + * Parse `git worktree list --porcelain`. `currentPath` flags which worktree is + * the requested project (isCurrent). The first record is the main worktree. + * Pure + unit-tested. + */ +export function parseWorktrees(porcelain: string, currentPath: string): WorktreeInfo[] { + const out: WorktreeInfo[] = [] + let cur: { path?: string; branch?: string; head?: string; locked?: boolean; prunable?: boolean } = {} + + const flush = (): void => { + if (cur.path === undefined) return + out.push({ + path: cur.path, + branch: cur.branch, + head: cur.head, + isMain: out.length === 0, // git lists the main worktree first + isCurrent: cur.path === currentPath, + locked: cur.locked, + prunable: cur.prunable, + }) + cur = {} + } + + for (const raw of porcelain.split('\n')) { + const line = raw.trimEnd() + if (line === '') { + flush() + continue + } + const sep = line.indexOf(' ') + const key = sep === -1 ? line : line.slice(0, sep) + const val = sep === -1 ? '' : line.slice(sep + 1) + if (key === 'worktree') cur.path = val + else if (key === 'HEAD') cur.head = val.slice(0, 8) + else if (key === 'branch') cur.branch = val.replace(/^refs\/heads\//, '') + else if (key === 'detached') cur.branch = undefined + else if (key === 'locked') cur.locked = true + else if (key === 'prunable') cur.prunable = true + } + flush() // final record has no trailing blank line + + return out +} + +/** List worktrees for a repo (execFile, no shell); [] on any error/non-repo. */ +export async function listWorktrees(repoPath: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { + cwd: repoPath, + timeout: WORKTREE_TIMEOUT_MS, + maxBuffer: WORKTREE_MAX_BUFFER, + }) + return parseWorktrees(stdout, repoPath) + } catch { + return [] + } +} diff --git a/src/server.ts b/src/server.ts index 9e299a6..c4defd9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -39,7 +39,7 @@ import { parseClientMessage, serialize } from './protocol.js' import { isOriginAllowed } from './http/origin.js' import { parseHookEvent } from './http/hook.js' import { listSessions } from './http/history.js' -import { buildProjects } from './http/projects.js' +import { buildProjects, buildProjectDetail } from './http/projects.js' import { openInEditor } from './http/editor.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, setClientDims } from './session/session.js' @@ -170,6 +170,28 @@ export function startServer(cfg: Config): { close(): Promise } { } }) + // Project detail (v0.6) — branch/worktrees + running sessions for one repo. + // Read-only (git branch/status/worktree-list); same threat model as /projects. + // ?path must be an absolute existing directory (validated in buildProjectDetail). + app.get('/projects/detail', async (req, res) => { + const target = req.query['path'] + if (typeof target !== 'string' || target === '') { + res.status(400).json({ error: 'path query parameter is required' }) + return + } + try { + const detail = await buildProjectDetail(cfg, target, manager.list()) + if (detail === null) { + res.status(404).json({ error: 'project not found' }) + return + } + res.json(detail) + } catch (err) { + console.error('[server] /projects/detail failed:', err instanceof Error ? err.message : String(err)) + res.status(500).json({ error: 'failed to read project detail' }) + } + }) + // Preview (v0.4 manage page) — the tail of a session's scrollback so the grid // can render a live read-only thumbnail of its current screen. No client/attach. app.get('/live-sessions/:id/preview', (req, res) => { diff --git a/src/types.ts b/src/types.ts index ecaa976..0e4cd86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -223,6 +223,29 @@ export interface ProjectInfo { sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty) } +/** One entry from `git worktree list --porcelain` (project detail view). */ +export interface WorktreeInfo { + path: string; // worktree absolute path + branch?: string; // branch name (refs/heads/x → x); undefined when detached + head?: string; // short HEAD sha + isMain: boolean; // the repo's primary worktree (listed first) + isCurrent: boolean; // this worktree is the requested project path + locked?: boolean; // `git worktree lock`ed + prunable?: boolean; // git considers it prunable (gone working tree) +} + +/** Detailed view of one project: branch/worktrees + its running sessions. + * impl: src/http/projects.ts buildProjectDetail(cfg, path, liveSessions). */ +export interface ProjectDetail { + name: string; + path: string; + isGit: boolean; + branch?: string; + dirty?: boolean; + worktrees: WorktreeInfo[]; // empty for a non-git dir + sessions: ProjectSessionRef[]; // running sessions under this path (fresh) +} + export interface SessionManager { handleAttach( ws: WebSocketLike, diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index d532990..cdc1285 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -21,8 +21,16 @@ class FakeTerminal { vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal })) // Dynamic import AFTER mock declaration so the factory is in place. -const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject, makeProjectCard } = - await import('../public/projects.js') +const { + filterProjects, + sortProjects, + getFavs, + saveFavs, + toggleFav, + normalizeProject, + makeProjectCard, + renderProjectDetail, +} = await import('../public/projects.js') /* ── Helpers ───────────────────────────────────────────────────────────────── */ @@ -352,4 +360,109 @@ describe('makeProjectCard — launcher row', () => { const card = makeProjectCard(p, new Set(), noopHooks(), () => {}) expect(card.querySelector('.proj-launch-claude .proj-launch-badge')?.textContent).toBe('2') }) + + it('makes the name a detail link only when onOpenDetail is given', () => { + const onOpenDetail = vi.fn() + const card = makeProjectCard(makeProject({ path: '/p/r' }), new Set(), noopHooks(), () => {}, onOpenDetail) + const name = card.querySelector('.proj-name') as HTMLElement + expect(name.classList.contains('proj-name-link')).toBe(true) + name.click() + expect(onOpenDetail).toHaveBeenCalledWith('/p/r') + + const plain = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {}) + expect(plain.querySelector('.proj-name')!.classList.contains('proj-name-link')).toBe(false) + }) +}) + +/* ── renderProjectDetail (branch/worktrees + active sessions) ───────────────── */ + +describe('renderProjectDetail', () => { + const hooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() }) + const cbs = () => ({ onBack: vi.fn(), onKill: vi.fn() }) + + function detail(over: Partial = {}) { + return { + name: 'repo', + path: '/p/repo', + isGit: true, + branch: 'main', + worktrees: [], + sessions: [], + ...over, + } + } + + it('renders "Project not found" + working Back for a null detail', () => { + const cb = cbs() + const root = renderProjectDetail(null, hooks(), cb) + expect(root.textContent).toContain('Project not found') + ;(root.querySelector('.proj-back') as HTMLButtonElement).click() + expect(cb.onBack).toHaveBeenCalled() + }) + + it('shows the header name, path, and branch', () => { + const root = renderProjectDetail(detail(), hooks(), cbs()) + expect(root.querySelector('.proj-detail-name')?.textContent).toBe('repo') + expect(root.querySelector('.proj-detail-path')?.textContent).toBe('/p/repo') + expect(root.querySelector('.proj-branch')?.textContent).toBe('main') + }) + + it('lists worktrees with branch, tags, and path', () => { + const root = renderProjectDetail( + detail({ + worktrees: [ + { path: '/p/repo', branch: 'main', head: 'abc12345', isMain: true, isCurrent: true }, + { path: '/p/wt/feat', branch: 'feature/x', head: 'def67890', isMain: false, isCurrent: false }, + ], + }), + hooks(), + cbs(), + ) + const rows = root.querySelectorAll('.proj-wt-row') + expect(rows).toHaveLength(2) + expect(rows[0]?.querySelector('.proj-wt-branch')?.textContent).toBe('main') + expect(rows[0]?.querySelector('.proj-wt-tag-current')).not.toBeNull() + expect(rows[1]?.querySelector('.proj-wt-branch')?.textContent).toBe('feature/x') + }) + + it('renders detached worktrees as "detached @ head"', () => { + const root = renderProjectDetail( + detail({ worktrees: [{ path: '/p/repo', head: 'deadbeef', isMain: true, isCurrent: true }] }), + hooks(), + cbs(), + ) + expect(root.querySelector('.proj-wt-branch')?.textContent).toBe('detached @ deadbeef') + }) + + it('lists active sessions with Open + Kill wired, ignoring exited ones', () => { + const h = hooks() + const cb = cbs() + const root = renderProjectDetail( + detail({ + sessions: [ + { id: 's1', title: 'claude', status: 'working', clientCount: 2, createdAt: 1, exited: false }, + { id: 'gone', status: 'idle', clientCount: 0, createdAt: 1, exited: true }, + ], + }), + h, + cb, + ) + const rows = root.querySelectorAll('.proj-dsession') + expect(rows).toHaveLength(1) + ;(rows[0]!.querySelector('.proj-dsession-open') as HTMLButtonElement).click() + expect(h.onEnterSession).toHaveBeenCalledWith('s1') + ;(rows[0]!.querySelector('.proj-dsession-kill') as HTMLButtonElement).click() + expect(cb.onKill).toHaveBeenCalledWith('s1') + }) + + it('shows an empty-state when no sessions are running, plus the launcher row', () => { + const root = renderProjectDetail(detail(), hooks(), cbs()) + expect(root.textContent).toContain('No running sessions') + expect(root.querySelectorAll('.proj-launch')).toHaveLength(3) + }) + + it('flags a non-git directory', () => { + const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs()) + expect(root.textContent).toContain('Not a git repository') + }) }) diff --git a/test/projects.test.ts b/test/projects.test.ts index 6cbed9b..fcf1113 100644 --- a/test/projects.test.ts +++ b/test/projects.test.ts @@ -14,7 +14,9 @@ vi.mock('../src/http/history.js', () => ({ listSessions: (...a: unknown[]) => mockListSessions(...a), })) -const { parseGitHead, buildProjects, _clearProjectCache } = await import('../src/http/projects.js') +const { parseGitHead, buildProjects, buildProjectDetail, _clearProjectCache } = await import( + '../src/http/projects.js' +) const execFileP = promisify(execFile) @@ -347,3 +349,73 @@ describe('buildProjects — dirty check', () => { expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined() }) }) + +// ── buildProjectDetail ─────────────────────────────────────────────────────────── + +describe('buildProjectDetail', () => { + it('returns null for a relative path', async () => { + expect(await buildProjectDetail(makeCfg({}), 'relative/dir', [])).toBeNull() + }) + + it('returns null for a non-existent path', async () => { + expect(await buildProjectDetail(makeCfg({}), path.join(tmp, 'nope'), [])).toBeNull() + }) + + it('returns null when the path is a file', async () => { + const f = path.join(tmp, 'file.txt') + await fs.writeFile(f, 'x') + expect(await buildProjectDetail(makeCfg({}), f, [])).toBeNull() + }) + + it('reports a non-git directory with no worktrees', async () => { + const dir = path.join(tmp, 'plain') + await fs.mkdir(dir) + const d = await buildProjectDetail(makeCfg({}), dir, []) + expect(d).not.toBeNull() + expect(d!.isGit).toBe(false) + expect(d!.worktrees).toEqual([]) + expect(d!.name).toBe('plain') + }) + + it('reads the branch for a git repo (worktrees best-effort)', async () => { + const dir = path.join(tmp, 'repo') + await makeRepo(dir, 'develop') + const d = await buildProjectDetail(makeCfg({}), dir, []) + expect(d!.isGit).toBe(true) + expect(d!.branch).toBe('develop') + expect(Array.isArray(d!.worktrees)).toBe(true) // real `git` fails on the fake .git → [] + }) + + it('merges running sessions whose cwd is the project or under it', async () => { + const dir = path.join(tmp, 'repo') + await fs.mkdir(dir) + const live = [ + makeLive({ id: 'a', cwd: dir, status: 'working' }), + makeLive({ id: 'b', cwd: path.join(dir, 'sub') }), + makeLive({ id: 'c', cwd: path.join(tmp, 'other') }), + ] + const d = await buildProjectDetail(makeCfg({}), dir, live) + expect(d!.sessions.map((s) => s.id).sort()).toEqual(['a', 'b']) + }) + + it('lists worktrees for a real git repo (gated on git)', async () => { + if (!(await gitAvailable())) return + await fs.mkdir(path.join(tmp, 'realrepo')) + // git canonicalises paths (/var → /private/var on macOS), so pass the + // realpath — real project paths from discovery are already canonical. + const dir = await fs.realpath(path.join(tmp, 'realrepo')) + await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir }) + await execFileP('git', ['config', 'user.email', 't@t'], { cwd: dir }) + await execFileP('git', ['config', 'user.name', 't'], { cwd: dir }) + await fs.writeFile(path.join(dir, 'f.txt'), 'hi') + await execFileP('git', ['add', '.'], { cwd: dir }) + await execFileP('git', ['commit', '-q', '-m', 'init'], { cwd: dir }) + + const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), dir, []) + expect(d!.isGit).toBe(true) + expect(d!.branch).toBe('main') + expect(d!.worktrees.length).toBeGreaterThanOrEqual(1) + expect(d!.worktrees[0]).toMatchObject({ isMain: true, isCurrent: true, branch: 'main' }) + expect(d!.dirty).toBe(false) + }) +}) diff --git a/test/worktrees.test.ts b/test/worktrees.test.ts new file mode 100644 index 0000000..092116f --- /dev/null +++ b/test/worktrees.test.ts @@ -0,0 +1,83 @@ +/** + * test/worktrees.test.ts — parseWorktrees (pure parser for `git worktree list + * --porcelain`). The git wrapper itself is covered via the projects-detail + * integration test in projects.test.ts. + */ + +import { describe, it, expect } from 'vitest' +import { parseWorktrees } from '../src/http/worktrees.js' + +const MAIN = [ + 'worktree /repo/main', + 'HEAD 1234567890abcdef1234567890abcdef12345678', + 'branch refs/heads/main', + '', +].join('\n') + +describe('parseWorktrees', () => { + it('parses a single main worktree on a branch', () => { + const wts = parseWorktrees(MAIN, '/repo/main') + expect(wts).toHaveLength(1) + expect(wts[0]).toMatchObject({ + path: '/repo/main', + branch: 'main', + head: '12345678', // first 8 of the sha + isMain: true, + isCurrent: true, + }) + }) + + it('marks only the first record as main; flags the current path', () => { + const text = [ + 'worktree /repo/main', + 'HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'branch refs/heads/main', + '', + 'worktree /repo/wt/feature', + 'HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'branch refs/heads/feature/login', + '', + ].join('\n') + const wts = parseWorktrees(text, '/repo/wt/feature') + expect(wts).toHaveLength(2) + expect(wts[0]).toMatchObject({ path: '/repo/main', branch: 'main', isMain: true, isCurrent: false }) + expect(wts[1]).toMatchObject({ + path: '/repo/wt/feature', + branch: 'feature/login', // refs/heads/ stripped, slashes kept + isMain: false, + isCurrent: true, + }) + }) + + it('handles a detached HEAD (no branch)', () => { + const text = ['worktree /repo/x', 'HEAD cccccccccccccccccccccccccccccccccccccccc', 'detached', ''].join('\n') + const wts = parseWorktrees(text, '/other') + expect(wts[0]?.branch).toBeUndefined() + expect(wts[0]?.head).toBe('cccccccc') + }) + + it('captures locked and prunable flags', () => { + const text = [ + 'worktree /repo/locked', + 'HEAD dddddddddddddddddddddddddddddddddddddddd', + 'branch refs/heads/wip', + 'locked', + 'prunable gitdir file points to non-existent location', + '', + ].join('\n') + const wts = parseWorktrees(text, '/x') + expect(wts[0]?.locked).toBe(true) + expect(wts[0]?.prunable).toBe(true) + }) + + it('parses the final record even without a trailing blank line', () => { + const text = ['worktree /repo/main', 'HEAD eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 'branch refs/heads/main'].join( + '\n', + ) + expect(parseWorktrees(text, '/repo/main')).toHaveLength(1) + }) + + it('returns [] for empty input', () => { + expect(parseWorktrees('', '/x')).toEqual([]) + }) +})