/** * 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, ): 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, ): 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, ): ProjectGroup[] { const buckets = new Map() 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 { 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 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): 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, path: string): Set { 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 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 { 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 { try { const res = await fetch(`/projects/detail?path=${encodeURIComponent(projectPath)}`) if (!res.ok) return null return (await res.json()) as ProjectDetail } catch { return null } } /** Kill a running session (manage-page DELETE; same-origin → Origin guard passes). */ async function killSession(id: string): Promise { try { await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) } catch { // best-effort — the refresh that follows will reflect the real state } } /* ── 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 { 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 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 { 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 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 `). */ async function openProjectInEditor(repoPath: string): Promise { 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, 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)['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 { 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 = new Set() // Namespace group collapse-state (true = collapsed); loaded from server prefs. let collapsed: Record = {} let prefsLoaded = false let timer: ReturnType | 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 { 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 { 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() }, } }