Files
web-terminal/public/projects.ts
Yaojia Wang 1dd12b035a feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
2026-07-12 21:27:20 +02:00

1094 lines
38 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
}
}
/* ── 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) ─────────────────────── */
/** 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
}
/** 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
}
/** 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
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)
}
// 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())
}
/** 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()
})
},
},
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()
},
}
}