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:
@@ -146,6 +146,18 @@
|
||||
- **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 按钮
|
||||
|
||||
- **状态**: `[x]` DONE。覆盖率 90.6/81.48/88.71/92.71(≥80×4)· 双 typecheck 干净 · `build:web` OK · 真浏览器 + 真 VS Code 端到端验证通过。
|
||||
|
||||
@@ -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<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 ──────────────────────────────────────────────────────── */
|
||||
|
||||
function sessionDotClass(status: ClaudeStatus): string {
|
||||
@@ -256,6 +282,7 @@ export function makeProjectCard(
|
||||
favs: ReadonlySet<string>,
|
||||
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<string> = getFavs()
|
||||
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 {
|
||||
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) => {
|
||||
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<void> {
|
||||
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(() => {
|
||||
|
||||
154
public/style.css
154
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);
|
||||
|
||||
@@ -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<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
73
src/http/worktrees.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
// can render a live read-only thumbnail of its current screen. No client/attach.
|
||||
app.get('/live-sessions/:id/preview', (req, res) => {
|
||||
|
||||
23
src/types.ts
23
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,
|
||||
|
||||
@@ -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<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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
83
test/worktrees.test.ts
Normal file
83
test/worktrees.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user