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

@@ -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) => {
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<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(() => {