Files
web-terminal/public/projects.ts
Yaojia Wang 552f35c690 feat(projects): worktree remove + prune from any device (W4)
Closes the create-only loop — delete losing worktrees and prune stale ones without
a terminal. Destructive, so guarded hard:

- src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never
  rm -rf; git worktree remove [--force] / git worktree prune). Safeguards:
  (1) target realpath must match an entry git itself reports in `git worktree list`
  for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never
  invoked against them); (2) reject the MAIN worktree → 400; (3) realpath
  containment on request + each list entry, operating on git's canonical path (+ --);
  (4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors
  classified to fixed safe strings (raw stderr/paths never leaked).
- DELETE /projects/worktree + POST /projects/worktree/prune — both behind
  requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route.
- public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked)
  + a prune button; force needs an explicit second confirm (no single-click data loss).

Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers
reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force /
clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
2026-07-12 21:44:23 +02:00

1259 lines
45 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* public/projects.ts — Projects panel for the home screen (v0.6).
*
* Renders a grid of discovered git repositories fetched from GET /projects.
* Each card shows: repo name, branch chip, dirty indicator, last-active time,
* and running sessions (1:N). Supports live search and per-project favourites
* persisted to localStorage.
*
* Pure helpers (filterProjects, sortProjects, getFavs, saveFavs, toggleFav)
* are exported for unit testing without DOM. mountProjects is the thin wiring.
*
* Timer lifecycle mirrors launcher.ts: auto-refresh every 5 s while visible,
* stopped + cleaned up on hide.
*/
import type {
ProjectInfo,
ProjectSessionRef,
ClaudeStatus,
ProjectDetail,
WorktreeInfo,
CreateWorktreeResult,
} from '../src/types.js'
import { el, relTime, statusText } from './preview-grid.js'
import { loadPrefs, savePrefs } from './prefs.js'
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
import { mountDiffViewer, type DiffViewerHandle } from './diff.js'
import { mountPrChip, type PrChipHandle } from './gh-chip.js'
import { mountGitLog, type GitLogHandle } from './git-log.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
/* ── Constants ───────────────────────────────────────────────────────────── */
const FAV_KEY = 'web-terminal:proj-favs'
const REFRESH_MS = 5000
/* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */
export interface ProjectsHooks {
/** Spawn a new terminal session in the repo running `cmd` (default: claude). */
onOpenProject: (repoPath: string, repoName: string, cmd?: string) => void
/** Enter an already-running session by id. */
onEnterSession: (id: string) => void
}
export interface ProjectsPanel {
setVisible(v: boolean): void
refresh(): void
}
/* ── Pure helpers (exported for unit tests) ──────────────────────────────── */
/**
* Filter projects by name or path substring (case-insensitive).
* Returns a new array; never mutates the input.
*/
export function filterProjects(projects: readonly ProjectInfo[], query: string): ProjectInfo[] {
const trimmed = query.trim()
if (!trimmed) return [...projects]
const lower = trimmed.toLowerCase()
return projects.filter(
(p) => p.name.toLowerCase().includes(lower) || p.path.toLowerCase().includes(lower),
)
}
/**
* Sort projects: favourites first, then by lastActiveMs descending.
* Returns a new array; never mutates the input.
*/
export function sortProjects(
projects: readonly ProjectInfo[],
favs: ReadonlySet<string>,
): ProjectInfo[] {
return [...projects].sort((a, b) => {
const aFav = favs.has(a.path) ? 0 : 1
const bFav = favs.has(b.path) ? 0 : 1
if (aFav !== bFav) return aFav - bFav
return (b.lastActiveMs ?? 0) - (a.lastActiveMs ?? 0)
})
}
/* ── Namespace grouping (v0.6) — turn the flat grid into collapsible sections ── */
/** Sentinel group keys (can't collide with a real `First.Second` namespace). */
export const ACTIVE_GROUP_KEY = 'active'
export const OTHER_GROUP_KEY = 'other'
/** A namespace needs at least this many members to earn its own section. */
const MIN_GROUP_SIZE = 2
export type ProjectGroupKind = 'active' | 'namespace' | 'other' | 'flat'
export interface ProjectGroup {
key: string // stable id for collapse-state persistence
label: string // header text
kind: ProjectGroupKind
projects: ProjectInfo[] // already sorted (favs-first, then recency)
activeCount: number // projects with a running session (for the header badge)
}
/** First two dot-segments of a repo name (the namespace), or undefined if none. */
function namespaceKey(name: string): string | undefined {
const segs = name.split('.')
if (segs.length < 2) return undefined
return segs.slice(0, 2).join('.')
}
/** The card label inside a group: the tail after the namespace prefix so the
* repeated `Billo.Platform.` isn't shouted on every card. Full name elsewhere. */
export function displayLabel(name: string, groupKey: string): string {
if (groupKey === ACTIVE_GROUP_KEY || groupKey === OTHER_GROUP_KEY) return name
const prefix = `${groupKey}.`
return name.toLowerCase().startsWith(prefix.toLowerCase()) ? name.slice(prefix.length) : name
}
function hasRunningSession(p: ProjectInfo): boolean {
return p.sessions.some((s) => !s.exited)
}
function buildGroup(
key: string,
label: string,
kind: ProjectGroupKind,
items: readonly ProjectInfo[],
favs: ReadonlySet<string>,
): ProjectGroup {
return {
key,
label,
kind,
projects: sortProjects(items, favs),
activeCount: items.filter(hasRunningSession).length,
}
}
function maxLastActive(items: readonly ProjectInfo[]): number {
return items.reduce((m, p) => Math.max(m, p.lastActiveMs ?? 0), 0)
}
/**
* Group projects into collapsible sections by dotted-namespace prefix.
* - Projects with a running session are duplicated into a pinned "Active now".
* - Namespaces with fewer than MIN_GROUP_SIZE members fall into "Other".
* - No real namespace groups → a single 'flat' group (render with no chrome),
* so a user whose repos share no prefix keeps today's plain grid.
* Never mutates the input.
*/
export function groupProjects(
projects: readonly ProjectInfo[],
favs: ReadonlySet<string>,
): ProjectGroup[] {
const buckets = new Map<string, { display: string; items: ProjectInfo[] }>()
const noNamespace: ProjectInfo[] = []
for (const p of projects) {
const ns = namespaceKey(p.name)
if (ns === undefined) {
noNamespace.push(p)
continue
}
const lowerKey = ns.toLowerCase()
const bucket = buckets.get(lowerKey)
if (bucket) bucket.items.push(p)
else buckets.set(lowerKey, { display: ns, items: [p] })
}
const other: ProjectInfo[] = [...noNamespace]
const namespaceGroups: ProjectGroup[] = []
for (const { display, items } of buckets.values()) {
if (items.length < MIN_GROUP_SIZE) other.push(...items)
else namespaceGroups.push(buildGroup(display, display, 'namespace', items, favs))
}
// Grouping bought nothing — fall back to a single flat, header-less grid.
if (namespaceGroups.length === 0) {
return [buildGroup(OTHER_GROUP_KEY, 'All projects', 'flat', projects, favs)]
}
// Namespace sections ordered by their most-recently-active member.
namespaceGroups.sort((a, b) => {
const diff = maxLastActive(b.projects) - maxLastActive(a.projects)
return diff !== 0 ? diff : a.label.localeCompare(b.label)
})
const groups: ProjectGroup[] = []
const active = projects.filter(hasRunningSession)
if (active.length > 0) {
groups.push(buildGroup(ACTIVE_GROUP_KEY, 'Active now', 'active', active, favs))
}
groups.push(...namespaceGroups)
if (other.length > 0) {
groups.push(buildGroup(OTHER_GROUP_KEY, 'Other', 'other', other, favs))
}
return groups
}
/** Load persisted favourite project paths from localStorage. */
export function getFavs(): Set<string> {
try {
const raw = localStorage.getItem(FAV_KEY)
if (!raw) return new Set()
const parsed: unknown = JSON.parse(raw)
// Never trust persisted data: keep only string entries (corrupt/tampered
// localStorage could otherwise poison the Set<string> used for path matching).
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((x): x is string => typeof x === 'string'))
} catch {
return new Set()
}
}
/** Persist a set of favourite project paths to localStorage. */
export function saveFavs(favs: ReadonlySet<string>): void {
try {
localStorage.setItem(FAV_KEY, JSON.stringify([...favs]))
} catch {
// localStorage unavailable — run without persistence
}
}
/**
* Immutable toggle: add if absent, remove if present.
* Returns a new Set; never mutates the original.
*/
export function toggleFav(favs: ReadonlySet<string>, path: string): Set<string> {
const next = new Set(favs)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
}
/* ── Fetch ───────────────────────────────────────────────────────────────── */
/** Coerce one untrusted /projects element into a safe ProjectInfo, or null.
* Guarantees `sessions` is always an array so card rendering never throws on a
* malformed response (never trust external data — API responses included). */
export function normalizeProject(p: unknown): ProjectInfo | null {
if (p === null || typeof p !== 'object') return null
const o = p as Record<string, unknown>
if (typeof o['name'] !== 'string' || typeof o['path'] !== 'string') return null
const sessions = Array.isArray(o['sessions']) ? (o['sessions'] as ProjectSessionRef[]) : []
return {
name: o['name'],
path: o['path'],
isGit: o['isGit'] === true,
...(typeof o['branch'] === 'string' ? { branch: o['branch'] } : {}),
...(typeof o['dirty'] === 'boolean' ? { dirty: o['dirty'] } : {}),
...(typeof o['lastActiveMs'] === 'number' ? { lastActiveMs: o['lastActiveMs'] } : {}),
// W3(a) sync chip fields — drop non-numbers (never trust the API shape).
...(typeof o['ahead'] === 'number' ? { ahead: o['ahead'] } : {}),
...(typeof o['behind'] === 'number' ? { behind: o['behind'] } : {}),
...(typeof o['lastCommitMs'] === 'number' ? { lastCommitMs: o['lastCommitMs'] } : {}),
sessions,
}
}
async function fetchProjects(): Promise<ProjectInfo[]> {
try {
const res = await fetch('/projects')
const data: unknown = await res.json()
if (!Array.isArray(data)) return []
return data.map(normalizeProject).filter((x): x is ProjectInfo => x !== null)
} catch {
return []
}
}
/** 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
}
}
/* ── W4 worktree remove / prune (same-origin → Origin guard passes) ─────────── */
interface WtActionResult {
ok: boolean
status?: number
error?: string
}
/** DELETE /projects/worktree — remove one linked worktree. Never throws; a
* network failure surfaces as { ok:false } so the caller can show an error. */
export async function removeWorktreeReq(
repoPath: string,
worktreePath: string,
force: boolean,
): Promise<WtActionResult> {
try {
const res = await fetch('/projects/worktree', {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: repoPath, worktreePath, force }),
})
let data: unknown
try { data = await res.json() } catch { data = null }
const d = (data ?? {}) as Record<string, unknown>
return {
ok: res.ok && d['ok'] === true,
status: res.status,
error: typeof d['error'] === 'string' ? d['error'] : undefined,
}
} catch {
return { ok: false, error: 'Failed to remove worktree' }
}
}
/** POST /projects/worktree/prune — reclaim stale worktrees. Never throws. */
export async function pruneWorktreesReq(repoPath: string): Promise<WtActionResult> {
try {
const res = await fetch('/projects/worktree/prune', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: repoPath }),
})
let data: unknown
try { data = await res.json() } catch { data = null }
const d = (data ?? {}) as Record<string, unknown>
return {
ok: res.ok && d['ok'] === true,
status: res.status,
error: typeof d['error'] === 'string' ? d['error'] : undefined,
}
} catch {
return { ok: false, error: 'Failed to prune worktrees' }
}
}
/** Remove-worktree confirm flow (exported so mountProjects and tests share the
* real code). Confirms once; on a 409 dirty-tree refusal, confirms a SECOND
* time before retrying with force — no single-click data loss. `onError`
* receives a safe message (rendered via textContent by the caller, SEC-L3/H6).
* Returns 'removed' (caller refreshes), 'cancelled', or 'error'. */
export async function confirmAndRemoveWorktree(
repoPath: string,
worktreePath: string,
onError: (msg: string) => void,
): Promise<'removed' | 'cancelled' | 'error'> {
if (!window.confirm(`Remove worktree at ${worktreePath}? This deletes the working tree.`)) {
return 'cancelled'
}
const first = await removeWorktreeReq(repoPath, worktreePath, false)
if (first.ok) return 'removed'
if (first.status === 409) {
if (!window.confirm('Uncommitted changes will be lost. Force-remove?')) return 'cancelled'
const forced = await removeWorktreeReq(repoPath, worktreePath, true)
if (forced.ok) return 'removed'
onError(forced.error ?? 'Failed to remove worktree')
return 'error'
}
onError(first.error ?? 'Failed to remove worktree')
return 'error'
}
/** Prune confirm flow (exported; shared by mountProjects and tests). Returns
* 'pruned' (caller refreshes), 'cancelled', or 'error'. */
export async function confirmAndPruneWorktrees(
repoPath: string,
onError: (msg: string) => void,
): Promise<'pruned' | 'cancelled' | 'error'> {
if (!window.confirm('Prune worktrees whose folders are gone?')) return 'cancelled'
const res = await pruneWorktreesReq(repoPath)
if (res.ok) return 'pruned'
onError(res.error ?? 'Failed to prune worktrees')
return 'error'
}
/* ── Status helpers ──────────────────────────────────────────────────────── */
function sessionDotClass(status: ClaudeStatus): string {
return `proj-session-dot proj-dot-${status}`
}
/* ── Card sub-elements ───────────────────────────────────────────────────── */
function makeSessionRow(
sess: { id: string; title?: string; status: ClaudeStatus; clientCount: number },
onEnterSession: (id: string) => void,
onKill?: (id: string) => void,
): HTMLElement {
const row = el('div', 'proj-session-row')
row.setAttribute('role', 'button')
row.tabIndex = 0
const dot = el('span', sessionDotClass(sess.status))
const title = el('span', 'proj-session-title', sess.title ?? 'claude')
const badge = el('span', 'proj-session-badge', `👁 ${sess.clientCount}`)
row.append(dot, title, badge)
if (onKill) {
const kill = el('button', 'proj-session-kill', '✕')
kill.title = 'Kill this session'
kill.setAttribute('aria-label', 'Kill session')
// Don't let the kill button trigger the row's enter handler.
kill.addEventListener('pointerdown', (e) => e.stopPropagation())
kill.addEventListener('click', (e) => {
e.stopPropagation()
onKill(sess.id)
})
row.append(kill)
}
row.addEventListener('click', () => onEnterSession(sess.id))
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onEnterSession(sess.id)
}
})
return row
}
/** Ask the host to open this project in the editor (POST /open-in-editor → `code <path>`). */
async function openProjectInEditor(repoPath: string): Promise<void> {
try {
const res = await fetch('/open-in-editor', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: repoPath }),
})
if (!res.ok) console.error('[projects] open in editor failed:', res.status)
} catch (err) {
console.error('[projects] open in editor error:', err)
}
}
interface LauncherOpts {
/** Highlight the button (a matching session is running). */
active?: boolean
/** Optional count badge (only shown when > 1). */
count?: number
}
/** One launcher button: brand logo (currentColor) + caption; colour set via CSS. */
function makeLauncher(
logo: string,
label: string,
cls: string,
title: string,
onClick: () => void,
opts?: LauncherOpts,
): HTMLElement {
const active = opts?.active === true
const btn = el('button', `proj-launch ${cls}${active ? ' proj-launch-active' : ''}`)
btn.title = title
btn.setAttribute('aria-label', title)
btn.innerHTML = logo // static, trusted brand SVG — no user data
btn.append(el('span', 'proj-launch-cap', label))
if (active && opts?.count !== undefined && opts.count > 1) {
btn.append(el('span', 'proj-launch-badge', String(opts.count)))
}
btn.addEventListener('click', (e) => {
e.stopPropagation()
onClick()
})
return btn
}
/** Count running Claude sessions in a project. A session counts as "Claude" once
* Claude Code hooks report a status (working/waiting/idle); plain shells and
* codex sessions stay 'unknown'. Used to highlight the Claude launcher. */
function activeClaudeCount(project: ProjectInfo): number {
return project.sessions.filter((s) => !s.exited && s.status !== 'unknown').length
}
/** Launcher row for a card: Claude · Codex (new terminal sessions) · VS Code (host editor). */
function makeLauncherRow(project: ProjectInfo, hooks: ProjectsHooks): HTMLElement {
const claude = activeClaudeCount(project)
const claudeTitle =
claude > 0
? `${claude} active Claude session${claude > 1 ? 's' : ''} in ${project.name} — start another`
: `Start Claude in ${project.name}`
const row = el('div', 'proj-launchers')
row.append(
makeLauncher(
CLAUDE_LOGO,
'Claude',
'proj-launch-claude',
claudeTitle,
() => hooks.onOpenProject(project.path, project.name, 'claude\r'),
{ active: claude > 0, count: claude },
),
makeLauncher(CODEX_LOGO, 'Codex', 'proj-launch-codex', `Start Codex in ${project.name}`, () =>
hooks.onOpenProject(project.path, project.name, 'codex\r'),
),
makeLauncher(VSCODE_LOGO, 'Code', 'proj-launch-vscode', `Open ${project.name} in VS Code`, () =>
void openProjectInEditor(project.path),
),
)
return row
}
/** W3(a) sync chip: `↑ahead ↓behind` vs upstream, with the last-commit time as a
* tooltip. Returns null when the repo is in sync (ahead=behind=0/undefined) so
* the chip only appears when there is drift worth showing. */
export function makeSyncChip(project: ProjectInfo): HTMLElement | null {
const ahead = project.ahead ?? 0
const behind = project.behind ?? 0
if (ahead <= 0 && behind <= 0) return null
const parts: string[] = []
if (ahead > 0) parts.push(`${ahead}`)
if (behind > 0) parts.push(`${behind}`)
const chip = el('span', 'proj-sync', parts.join(' '))
const tips: string[] = []
if (ahead > 0) tips.push(`${ahead} ahead of upstream`)
if (behind > 0) tips.push(`${behind} behind upstream`)
if (project.lastCommitMs !== undefined) tips.push(`last commit ${relTime(project.lastCommitMs)} ago`)
chip.title = tips.join(' · ')
return chip
}
export function makeProjectCard(
project: ProjectInfo,
favs: ReadonlySet<string>,
hooks: ProjectsHooks,
onToggleFav: (path: string) => void,
onOpenDetail?: (path: string) => void,
onKillSession?: (id: string) => void,
displayName?: string,
): HTMLElement {
const card = el('div', 'proj-card')
// ── Header ────────────────────────────────────────────────────────────────
const header = el('div', 'proj-card-head')
const isFav = favs.has(project.path)
const favBtn = el('button', isFav ? 'proj-fav on' : 'proj-fav')
favBtn.textContent = '★'
favBtn.title = isFav ? 'Remove from favourites' : 'Add to favourites'
favBtn.addEventListener('click', (e) => {
e.stopPropagation()
onToggleFav(project.path)
})
// Visible label may be the namespace tail (e.g. "Payment"); launch/detail still
// use the full project.name so tab titles and lookups stay unambiguous.
const name = el('span', 'proj-name', displayName ?? project.name)
name.title = 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)
if (project.branch) {
const branch = el('span', 'proj-branch', project.branch)
header.append(branch)
}
// W3(a): ahead/behind-vs-upstream sync chip (only when there's drift).
const syncChip = makeSyncChip(project)
if (syncChip !== null) header.append(syncChip)
if (project.dirty === true) {
const dirty = el('span', 'proj-dirty', '●')
dirty.title = 'Uncommitted changes'
header.append(dirty)
}
card.append(header)
// ── Meta line ─────────────────────────────────────────────────────────────
if (project.lastActiveMs !== undefined) {
const meta = el('div', 'proj-meta', `active ${relTime(project.lastActiveMs)} ago`)
card.append(meta)
}
// ── Sessions area (1:N) — list any running sessions above the launchers ─────
const runningSessions = project.sessions.filter((s) => !s.exited)
if (runningSessions.length > 0) {
const sessionsList = el('div', 'proj-sessions-list')
for (const sess of runningSessions) {
sessionsList.append(makeSessionRow(sess, hooks.onEnterSession, onKillSession))
}
card.append(sessionsList)
}
// ── Launchers (Claude · Codex · VS Code) — always available ─────────────────
card.append(makeLauncherRow(project, hooks))
return card
}
/* ── Detail view (branch / worktrees + active sessions) ─────────────────────── */
/** Actions a worktree row can offer (W4). Present → non-main/non-locked rows get
* a Remove (✕) button; a locked row keeps its tag but no button (unlock first). */
export interface WorktreeRowActions {
onRemove: (wt: WorktreeInfo) => void
}
/** One worktree row: branch (or detached@head) + main/current/locked tags + path,
* plus an optional Remove button (W4) for removable (non-main, non-locked) rows. */
export function makeWorktreeRow(wt: WorktreeInfo, actions?: WorktreeRowActions): 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))
// W4: only linked, unlocked worktrees are removable. The main worktree (the
// repo itself) and locked worktrees never get a button — matches the server's
// 400/409 refusals so the UI can't invite an action git will reject.
if (actions !== undefined && !wt.isMain && wt.locked !== true) {
const remove = el('button', 'proj-wt-remove', '✕')
remove.title = 'Remove this worktree'
remove.setAttribute('aria-label', 'Remove worktree')
remove.addEventListener('click', () => actions.onRemove(wt))
row.append(remove)
}
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
}
/** Returns null if `branch` is a valid git branch name, or an error message (B3). Never throws. */
export function validateBranchNameClient(branch: string): string | null {
if (!branch) return 'Branch name cannot be empty'
if (branch.length > 250) return 'Branch name is too long (max 250 characters)'
if (/[\x00-\x20\x7f]/.test(branch)) return 'Branch name cannot contain spaces or control characters'
if (branch.startsWith('-')) return 'Branch name cannot start with a hyphen'
if (branch.includes('..')) return 'Branch name cannot contain ".."'
if (branch.endsWith('.lock')) return 'Branch name cannot end with ".lock"'
if (/[~^:?*\[\\]/.test(branch)) return 'Branch name contains invalid characters'
if (branch.includes('@{')) return 'Branch name cannot contain "@{"'
if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) {
return 'Branch name has invalid slash usage'
}
return null
}
function isWorktreeResult(v: unknown): v is CreateWorktreeResult {
return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
}
/** Render a "New Worktree" form (B3). Errors via textContent only (SEC-L3/H6). */
export function renderNewWorktreeForm(detail: ProjectDetail, hooks: ProjectsHooks): HTMLElement {
const form = el('div', 'proj-wt-form')
const input = document.createElement('input')
input.type = 'text'
input.className = 'proj-wt-branch-input'
input.placeholder = 'New branch name'
input.setAttribute('aria-label', 'New branch name')
input.maxLength = 250
const errorEl = el('div', 'proj-wt-error')
errorEl.style.display = 'none'
const submitBtn = el('button', 'proj-wt-submit', 'Create Worktree')
form.append(input, errorEl, submitBtn)
function showError(msg: string): void {
errorEl.textContent = msg // SEC-L3/H6: textContent, never innerHTML
errorEl.style.display = ''
submitBtn.disabled = false
}
function hideError(): void { errorEl.textContent = ''; errorEl.style.display = 'none' }
let busy = false
async function doCreate(): Promise<void> {
if (busy) return
const branch = input.value.trim()
const err = validateBranchNameClient(branch)
if (err !== null) { showError(err); return }
hideError()
busy = true
submitBtn.disabled = true
try {
const res = await fetch('/projects/worktree', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: detail.path, branch }),
})
let data: unknown
try { data = await res.json() } catch { data = null }
if (!res.ok || !isWorktreeResult(data) || !data.ok) {
showError(isWorktreeResult(data) && typeof data.error === 'string'
? data.error : 'Failed to create worktree')
return
}
hooks.onOpenProject(data.path ?? detail.path, data.branch ?? branch, 'claude\r')
} catch {
showError('Failed to create worktree')
} finally {
busy = false
submitBtn.disabled = false
}
}
submitBtn.addEventListener('click', () => { void doCreate() })
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') void doCreate() })
input.addEventListener('input', () => { if (errorEl.style.display !== 'none') hideError() })
return form
}
/** Build the "View Diff" toggle + inline panel (B1). `diffRef.h` tracks the open
* handle. `bases` (branch names) feed the FR-B1.9 "compare against base" picker. */
function buildDiffSection(
repoPath: string,
diffRef: { h: DiffViewerHandle | null },
bases: string[],
): HTMLElement {
const section = el('div', 'proj-diff-section')
const toggle = el('button', 'proj-diff-toggle', 'View Diff')
const panel = el('div', 'proj-diff-panel')
panel.style.display = 'none'
toggle.addEventListener('click', () => {
if (panel.style.display === 'none') {
panel.style.display = ''
toggle.textContent = 'Hide Diff'
diffRef.h = mountDiffViewer(panel, repoPath, {
bases,
onClose: () => {
panel.style.display = 'none'
toggle.textContent = 'View Diff'
diffRef.h?.destroy()
diffRef.h = null
},
})
} else {
diffRef.h?.destroy()
diffRef.h = null
panel.style.display = 'none'
toggle.textContent = 'View Diff'
}
})
section.append(toggle, panel)
return section
}
/** Build the "Activity" section: one timeline panel per running session (A4). */
function buildActivitySection(
running: ProjectSessionRef[],
localTimelines: TimelineHandle[],
collector: TimelineHandle[] | undefined,
): HTMLElement | null {
if (running.length === 0) return null
const section = el('div', 'proj-activity-section')
for (const sess of running) {
const block = el('div', 'proj-activity-session')
block.append(el('div', 'proj-activity-label', sess.title ?? 'session'))
const tlContainer = el('div', 'proj-tl-container')
block.append(tlContainer)
const handle = mountTimeline(tlContainer, sess.id)
localTimelines.push(handle)
if (collector !== undefined) collector.push(handle)
section.append(block)
}
return section
}
interface DetailCallbacks {
onBack: () => void
onKill: (id: string) => void
// W4 (optional so pre-W4 callers/tests keep compiling): when provided,
// removable worktree rows gain a ✕ button and, if any worktree is prunable,
// the Worktrees header gains a "Prune stale worktrees" button.
onRemoveWorktree?: (worktreePath: string) => void
onPruneWorktrees?: () => void
}
/** Render the project detail view (exported for unit tests). `timelineCollector` receives A4 handles for re-render disposal. */
export function renderProjectDetail(
detail: ProjectDetail | null,
hooks: ProjectsHooks,
cb: DetailCallbacks,
timelineCollector?: TimelineHandle[],
): HTMLElement {
const root = el('div', 'proj-detail-inner')
const localTimelines: TimelineHandle[] = []
const diffRef: { h: DiffViewerHandle | null } = { h: null }
const prRef: { h: PrChipHandle | null } = { h: null }
const logRef: { h: GitLogHandle | null } = { h: null }
const back = el('button', 'proj-back', '← All projects')
back.addEventListener('click', () => {
diffRef.h?.destroy()
diffRef.h = null
prRef.h?.destroy()
prRef.h = null
logRef.h?.destroy()
logRef.h = null
for (const handle of localTimelines) handle.dispose()
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)
}
// W3: PR + CI status chip (git repos only). Read-only side-channel via `gh`;
// self-degrades (gh missing / unauthed / no PR) so it never blocks the header.
if (detail.isGit) {
const prHost = el('span', 'proj-pr-host')
head.append(prHost)
prRef.h = mountPrChip(prHost, detail.path)
}
root.append(head)
root.append(el('div', 'proj-detail-path', detail.path))
// B1: View Diff (git repos only) — SEC-H4 enforced inside mountDiffViewer.
// FR-B1.9: offer this repo's worktree branches + current branch as diff bases.
if (detail.isGit) {
const bases = Array.from(
new Set(
[...detail.worktrees.map((w) => w.branch), detail.branch].filter(
(b): b is string => typeof b === 'string' && b !== '',
),
),
)
root.append(buildDiffSection(detail.path, diffRef, bases))
}
// W3(d): Recent commits (git repos only) — read-only, inert-text rows.
if (detail.isGit) {
root.append(el('div', 'proj-section-title', 'Recent commits'))
const logHost = el('div', 'proj-commitlog')
root.append(logHost)
logRef.h = mountGitLog(logHost, detail.path)
}
// Branch / worktrees — header carries a "Prune stale worktrees" button (W4)
// when any worktree is prunable and a prune callback is wired.
const wtHead = el('div', 'proj-section-row')
wtHead.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch'))
if (cb.onPruneWorktrees !== undefined && detail.worktrees.some((w) => w.prunable === true)) {
const pruneBtn = el('button', 'proj-wt-prune', 'Prune stale worktrees')
pruneBtn.title = 'Remove worktrees whose folders are gone'
pruneBtn.addEventListener('click', () => cb.onPruneWorktrees?.())
wtHead.append(pruneBtn)
}
root.append(wtHead)
// W4: a shared, initially-hidden error line for remove/prune failures. Written
// via textContent only by mountProjects (SEC-L3/H6); persists until the next
// refresh rebuilds this detail view.
const wtError = el('div', 'proj-wt-actions-error')
wtError.style.display = 'none'
root.append(wtError)
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 wtActions: WorktreeRowActions | undefined =
cb.onRemoveWorktree !== undefined
? { onRemove: (wt) => cb.onRemoveWorktree?.(wt.path) }
: undefined
const list = el('div', 'proj-wt-list')
for (const wt of detail.worktrees) list.append(makeWorktreeRow(wt, wtActions))
root.append(list)
}
// B3: New Worktree form (git repos only)
if (detail.isGit) {
root.append(el('div', 'proj-section-title', 'New Worktree'))
root.append(renderNewWorktreeForm(detail, hooks))
}
// 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)
}
// A4: Activity section — one timeline per running session, disposed on back/re-render
const activityEl = buildActivitySection(running, localTimelines, timelineCollector)
if (activityEl !== null) {
root.append(el('div', 'proj-section-title', 'Activity'))
root.append(activityEl)
}
// CLAUDE.md
const cmHead = el('div', 'proj-section-row')
cmHead.append(el('div', 'proj-section-title', 'CLAUDE.md'))
const cmBtn = el('button', 'proj-claudemd-btn', detail.hasClaudeMd ? '↻ Update' : '✨ Generate')
cmBtn.title = detail.hasClaudeMd
? 'Open a Claude session running /init to refresh CLAUDE.md'
: 'Open a Claude session running /init to create CLAUDE.md'
cmBtn.addEventListener('click', () => hooks.onOpenProject(detail.path, detail.name, 'claude "/init"\r'))
cmHead.append(cmBtn)
root.append(cmHead)
if (detail.hasClaudeMd && detail.claudeMd !== undefined) {
const pre = el('pre', 'proj-claudemd')
pre.textContent = detail.claudeMd
root.append(pre)
} else {
root.append(
el('div', 'proj-empty', 'No CLAUDE.md yet — generate one to give Claude project-specific instructions.'),
)
}
// Launchers
root.append(el('div', 'proj-section-title', 'Start'))
root.append(makeLauncherRow(detail, hooks))
return root
}
/* ── Group header (collapsible namespace section) ───────────────────────────── */
/** One section header: caret + label + count + optional "N active" badge.
* "Active now" is not collapsible (always shown — it's the point). */
function makeGroupHeader(
group: ProjectGroup,
isCollapsed: boolean,
collapsible: boolean,
onToggle: () => void,
): HTMLElement {
const head = el('div', `proj-group-head proj-group-${group.kind}`)
const caret = el('span', 'proj-group-caret')
caret.textContent = collapsible ? (isCollapsed ? '▶' : '▼') : '●'
head.append(caret)
head.append(el('span', 'proj-group-label', group.label))
head.append(el('span', 'proj-group-count', String(group.projects.length)))
// On namespace/other headers, surface hidden running work so a collapsed
// section never silently buries an active session.
if (group.kind !== 'active' && group.activeCount > 0) {
head.append(el('span', 'proj-group-active', `${group.activeCount} active`))
}
if (collapsible) {
head.setAttribute('role', 'button')
head.tabIndex = 0
head.setAttribute('aria-expanded', String(!isCollapsed))
head.title = isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`
head.addEventListener('click', onToggle)
head.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
})
}
return head
}
/* ── Mount ───────────────────────────────────────────────────────────────── */
export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): ProjectsPanel {
const root = el('div', 'proj-panel')
root.style.display = 'none'
host.appendChild(root)
// Search box
const searchWrap = el('div', 'proj-search-wrap')
const searchInput = document.createElement('input')
searchInput.className = 'proj-search'
searchInput.type = 'search'
searchInput.placeholder = 'Filter projects…'
searchInput.setAttribute('aria-label', 'Filter projects')
searchWrap.append(searchInput)
root.append(searchWrap)
const grid = el('div', 'proj-grid')
root.append(grid)
const detailEl = el('div', 'proj-detail')
detailEl.style.display = 'none'
root.append(detailEl)
let allProjects: ProjectInfo[] = []
let favs: Set<string> = new Set()
// Namespace group collapse-state (true = collapsed); loaded from server prefs.
let collapsed: Record<string, boolean> = {}
let prefsLoaded = false
let timer: ReturnType<typeof setInterval> | null = null
let view: 'grid' | 'detail' = 'grid'
let detailPath: string | null = null
// A4: tracks timeline handles from the current detail render for re-render disposal
let detailTimelineHandles: TimelineHandle[] = []
/** Persist favourites + collapse-state to the server (best-effort, cross-device). */
function persist(): void {
void savePrefs({ favourites: [...favs], collapsed })
}
function onToggleFav(path: string): void {
favs = toggleFav(favs, path)
persist()
renderGrid()
}
function toggleCollapsed(key: string): void {
if (collapsed[key] === true) {
const next = { ...collapsed }
delete next[key] // store only the "collapsed" fact; expanded is the default
collapsed = next
} else {
collapsed = { ...collapsed, [key]: true }
}
persist()
renderGrid()
}
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 killAndRefresh(id: string): void {
void killSession(id).then(() => void refresh())
}
/** Render a remove/prune failure into the detail view's error line (textContent
* only, SEC-L3/H6). No-op if the element isn't present (e.g. mid-transition). */
function showWtActionError(msg: string): void {
const errEl = detailEl.querySelector('.proj-wt-actions-error')
if (errEl instanceof HTMLElement) {
errEl.textContent = msg
errEl.style.display = ''
}
}
/** W4: confirm → (force?) → refresh flow for removing one worktree. */
function removeWorktreeAndRefresh(worktreePath: string): void {
if (detailPath === null) return
void confirmAndRemoveWorktree(detailPath, worktreePath, showWtActionError).then((outcome) => {
if (outcome === 'removed' && view === 'detail') void refresh()
})
}
/** W4: confirm → prune → refresh flow for stale worktrees. */
function pruneWorktreesAndRefresh(): void {
if (detailPath === null) return
void confirmAndPruneWorktrees(detailPath, showWtActionError).then((outcome) => {
if (outcome === 'pruned' && view === 'detail') void refresh()
})
}
/** The card grid for one group's projects (tail labels inside namespace groups). */
function renderCards(group: ProjectGroup): HTMLElement {
const cards = el('div', 'proj-grid-cards')
for (const p of group.projects) {
cards.append(
makeProjectCard(
p,
favs,
hooks,
onToggleFav,
openDetail,
killAndRefresh,
displayLabel(p.name, group.key),
),
)
}
return cards
}
function renderGrid(): void {
const query = searchInput.value
const filtered = filterProjects(allProjects, query)
const searching = query.trim() !== ''
// Preserve scroll across the 5 s auto-refresh rebuild.
const prevScroll = root.scrollTop
grid.replaceChildren()
if (filtered.length === 0) {
const msg =
allProjects.length === 0
? 'No projects found. Check PROJECT_ROOTS configuration.'
: 'No projects match your search.'
grid.append(el('div', 'proj-empty', msg))
return
}
for (const group of groupProjects(filtered, favs)) {
if (group.kind === 'flat') {
grid.append(renderCards(group)) // no header — plain grid fallback
continue
}
const collapsible = group.kind === 'namespace' || group.kind === 'other'
// While searching, force every matching section open so results are never
// hidden behind a collapsed caret.
const isCollapsed = collapsible && !searching && collapsed[group.key] === true
const section = el('div', `proj-group${isCollapsed ? ' collapsed' : ''}`)
section.append(
makeGroupHeader(group, isCollapsed, collapsible, () => toggleCollapsed(group.key)),
)
if (!isCollapsed) section.append(renderCards(group))
grid.append(section)
}
root.scrollTop = prevScroll
}
function renderDetail(detail: ProjectDetail | null): void {
// Dispose timelines from the previous render before replacing DOM (A4 re-render)
for (const h of detailTimelineHandles) h.dispose()
detailTimelineHandles = []
detailEl.replaceChildren(
renderProjectDetail(
detail,
hooks,
{
onBack: closeDetail,
onKill: (id) => {
void killSession(id).then(() => {
if (view === 'detail') void refresh()
})
},
onRemoveWorktree: removeWorktreeAndRefresh,
onPruneWorktrees: pruneWorktreesAndRefresh,
},
detailTimelineHandles,
),
)
}
async function refresh(): Promise<void> {
if (view === 'detail' && detailPath !== null) {
renderDetail(await fetchProjectDetail(detailPath))
return
}
allProjects = await fetchProjects()
renderGrid()
}
/** Load cross-device prefs once, then refresh. Prefs stay in memory afterward
* (the 5 s tick only re-fetches projects, so it never clobbers local edits). */
async function init(): Promise<void> {
if (!prefsLoaded) {
const prefs = await loadPrefs()
favs = new Set(prefs.favourites)
collapsed = { ...prefs.collapsed }
prefsLoaded = true
}
await refresh()
}
searchInput.addEventListener('input', () => renderGrid())
return {
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 init()
if (timer === null) {
timer = setInterval(() => {
if (root.style.display !== 'none') void refresh()
}, REFRESH_MS)
}
} else {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
},
refresh(): void {
void refresh()
},
}
}