feat(v0.6): per-project detail view — branch/worktrees + active sessions

Clicking a project's name opens an in-app detail view (back button returns to
the grid) with:
- header: name, path, branch, dirty
- Branch / Worktrees: each `git worktree list` entry (branch or detached@head,
  main/current/locked/prunable tags, path) — a single-worktree repo shows its
  branch; a multi-worktree repo shows them all
- Active sessions (N): detailed rows (status, devices watching, started-ago)
  with Open + Kill
- the Claude/Codex/VS Code launcher row

Backend: src/http/worktrees.ts (parseWorktrees pure + listWorktrees via
execFile, no shell, best-effort); buildProjectDetail(cfg, path, liveSessions)
in projects.ts (validates absolute existing dir, reuses branch/dirty/session
helpers, uncached — detail is on-demand and wants fresh state); read-only
GET /projects/detail?path= (no Origin guard, like /projects; 400 missing /
404 invalid). Types: WorktreeInfo, ProjectDetail.

Frontend: detail view + navigation in projects.ts (grid<->detail), project
name becomes a link, Kill via DELETE /live-sessions/:id.

Tests +21 (452 total): worktree parser, buildProjectDetail (validation,
non-git, session merge, real git-init worktree), renderProjectDetail.
worktrees.ts 100% / projects.ts 93.8% cov. Verified in-browser: opened a
Claude session, then the detail showed its branch+worktree and the live
session with Open/Kill.
This commit is contained in:
Yaojia Wang
2026-06-30 13:29:41 +02:00
parent a390130205
commit 99bc6fd9f6
10 changed files with 810 additions and 12 deletions

View File

@@ -146,6 +146,18 @@
- **commit**: <hash 或 N/A> - **commit**: <hash 或 N/A>
======================================================= --> ======================================================= -->
### 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 按钮 ### 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 端到端验证通过。 - **状态**: `[x]` DONE。覆盖率 90.6/81.48/88.71/92.71(≥80×4)· 双 typecheck 干净 · `build:web` OK · 真浏览器 + 真 VS Code 端到端验证通过。

View File

@@ -13,8 +13,14 @@
* stopped + cleaned up on hide. * stopped + cleaned up on hide.
*/ */
import type { ProjectInfo, ProjectSessionRef, ClaudeStatus } from '../src/types.js' import type {
import { el, relTime } from './preview-grid.js' 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' import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
/* ── Constants ───────────────────────────────────────────────────────────── */ /* ── Constants ───────────────────────────────────────────────────────────── */
@@ -137,6 +143,26 @@ async function fetchProjects(): Promise<ProjectInfo[]> {
} }
} }
/** Fetch one project's detail (branch/worktrees + sessions). null on any error. */
async function fetchProjectDetail(projectPath: string): Promise<ProjectDetail | null> {
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<void> {
try {
await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
} catch {
// best-effort — the refresh that follows will reflect the real state
}
}
/* ── Status helpers ──────────────────────────────────────────────────────── */ /* ── Status helpers ──────────────────────────────────────────────────────── */
function sessionDotClass(status: ClaudeStatus): string { function sessionDotClass(status: ClaudeStatus): string {
@@ -256,6 +282,7 @@ export function makeProjectCard(
favs: ReadonlySet<string>, favs: ReadonlySet<string>,
hooks: ProjectsHooks, hooks: ProjectsHooks,
onToggleFav: (path: string) => void, onToggleFav: (path: string) => void,
onOpenDetail?: (path: string) => void,
): HTMLElement { ): HTMLElement {
const card = el('div', 'proj-card') const card = el('div', 'proj-card')
@@ -272,6 +299,19 @@ export function makeProjectCard(
}) })
const name = el('span', 'proj-name', project.name) 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) header.append(favBtn, name)
@@ -310,6 +350,112 @@ export function makeProjectCard(
return card 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 ───────────────────────────────────────────────────────────────── */ /* ── Mount ───────────────────────────────────────────────────────────────── */
export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): ProjectsPanel { 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') const grid = el('div', 'proj-grid')
root.append(grid) root.append(grid)
const detailEl = el('div', 'proj-detail')
detailEl.style.display = 'none'
root.append(detailEl)
let allProjects: ProjectInfo[] = [] let allProjects: ProjectInfo[] = []
let favs: Set<string> = getFavs() let favs: Set<string> = getFavs()
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | 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 { function renderGrid(): void {
const filtered = filterProjects(allProjects, searchInput.value) const filtered = filterProjects(allProjects, searchInput.value)
@@ -351,16 +524,39 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
for (const p of sorted) { for (const p of sorted) {
grid.append( grid.append(
makeProjectCard(p, favs, hooks, (path) => { makeProjectCard(
favs = toggleFav(favs, path) p,
saveFavs(favs) favs,
renderGrid() 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<void> { async function refresh(): Promise<void> {
if (view === 'detail' && detailPath !== null) {
renderDetail(await fetchProjectDetail(detailPath))
return
}
allProjects = await fetchProjects() allProjects = await fetchProjects()
favs = getFavs() favs = getFavs()
renderGrid() renderGrid()
@@ -372,6 +568,10 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
setVisible(v: boolean): void { setVisible(v: boolean): void {
root.style.display = v ? 'block' : 'none' root.style.display = v ? 'block' : 'none'
if (v) { if (v) {
// Re-entering the chooser always lands on the grid (never a stale detail).
view = 'grid'
detailPath = null
applyView()
void refresh() void refresh()
if (timer === null) { if (timer === null) {
timer = setInterval(() => { timer = setInterval(() => {

View File

@@ -1156,6 +1156,160 @@ body {
text-align: center; 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 */ /* Empty state */
.proj-empty { .proj-empty {
color: var(--text-dim); color: var(--text-dim);

View File

@@ -19,8 +19,15 @@ import path from 'node:path'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import type { Dirent } from 'node:fs' 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 { listSessions } from './history.js'
import { listWorktrees } from './worktrees.js'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -353,3 +360,42 @@ export async function buildProjects(
})) }))
return sortProjects(withSessions).slice(0, MAX_PROJECTS) 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<ProjectDetail | null> {
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),
}
}

73
src/http/worktrees.ts Normal file
View File

@@ -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<WorktreeInfo[]> {
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 []
}
}

View File

@@ -39,7 +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 { buildProjects, buildProjectDetail } from './http/projects.js'
import { openInEditor } from './http/editor.js' import { openInEditor } from './http/editor.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'
@@ -170,6 +170,28 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
} }
}) })
// 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 // 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) => {

View File

@@ -223,6 +223,29 @@ export interface ProjectInfo {
sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty) 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 { export interface SessionManager {
handleAttach( handleAttach(
ws: WebSocketLike, ws: WebSocketLike,

View File

@@ -21,8 +21,16 @@ class FakeTerminal {
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal })) vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
// Dynamic import AFTER mock declaration so the factory is in place. // Dynamic import AFTER mock declaration so the factory is in place.
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject, makeProjectCard } = const {
await import('../public/projects.js') filterProjects,
sortProjects,
getFavs,
saveFavs,
toggleFav,
normalizeProject,
makeProjectCard,
renderProjectDetail,
} = await import('../public/projects.js')
/* ── Helpers ───────────────────────────────────────────────────────────────── */ /* ── Helpers ───────────────────────────────────────────────────────────────── */
@@ -352,4 +360,109 @@ describe('makeProjectCard — launcher row', () => {
const card = makeProjectCard(p, new Set(), noopHooks(), () => {}) const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude .proj-launch-badge')?.textContent).toBe('2') 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<import('../src/types.js').ProjectDetail> = {}) {
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')
})
}) })

View File

@@ -14,7 +14,9 @@ vi.mock('../src/http/history.js', () => ({
listSessions: (...a: unknown[]) => mockListSessions(...a), 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) const execFileP = promisify(execFile)
@@ -347,3 +349,73 @@ describe('buildProjects — dirty check', () => {
expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined() 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)
})
})

83
test/worktrees.test.ts Normal file
View File

@@ -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([])
})
})