Files
web-terminal/public/projects.ts
Yaojia Wang 8fe1f52e5d feat(projects): show real git state on the project detail page
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.

One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:

  - `ahead` needs only local refs and is never flagged.
  - `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
  - Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
    "I checked, ignore this"; getting it wrong is lying to the user.
  - No upstream (the normal state of a fresh worktree branch) leaves ahead and
    behind undefined — it renders an explicit `no upstream`, never the green
    path. That fall-through is the easiest bug to ship here.

What landed:

G1  SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
    and .dirtyCount. All additive and optional — the Android and iOS clients
    decode these shapes. The ahead/behind helper already existed for the list
    view; buildProjectDetail had simply never called it.
    Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
    directly, so it returned nothing inside a LINKED worktree, where .git is a
    file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
    the shared common dir (FETCH_HEAD).

G2  POST /projects/git/fetch. Same discipline as push: the remote is derived
    server-side and no remote or refspec is ever read from the body, so a
    client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
    working tree, no index, no merge; it is not a pull. Own rate-limit bucket
    so refreshes cannot eat the budget a real push needs. On failure
    lastFetchMs is left alone, so the UI keeps saying "stale" instead of
    pretending it refreshed.

G3  makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
    dirty count, Fetch button (disabled on a detached HEAD).

G4  The commit list marks unpushed commits and draws the upstream boundary
    once, after the last of them. Marking is server-side from `rev-list`,
    deliberately NOT "the first N rows": `git log` is date-ordered, so merging
    an older branch interleaves unpushed commits BELOW pushed ones, and that
    shortcut fails in the dangerous direction — calling an unpushed commit
    pushed. A regression test builds exactly that backdated-merge shape.

G5  The worktree section is always "Worktrees (n)" (it used to rename itself
    to "Branch" at n=1) and the current row carries its own state chips.

G6  Cost control. The plan called for a .git-mtime cache; that was dropped
    during implementation because a fingerprint over HEAD/index/reflog does
    NOT move when push updates a remote-tracking ref — the cached `ahead`
    would still claim "9 to push" right after a successful push, which is the
    exact lie the feature exists to prevent. Replaced with three measures that
    cannot go stale: in-flight coalescing (N devices watching one repo cost
    one probe, entry dropped as it settles, nothing cached across time),
    skipping the re-render when the payload is byte-identical (this also stops
    the 5 s re-mount of the commit log, two more git spawns per tick), and
    pausing the timer while the document is hidden.

G7  Per-worktree state via GET /projects/worktree/state, kept narrower than
    /projects/detail so N rows do not pay for worktree listing and CLAUDE.md
    reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
    carried no cwd, so sessions could not be attributed to a worktree. Added
    it, plus countSessionsByWorktree, which matches DEEPEST-first because
    .claude/worktrees/<name> lives INSIDE the main checkout and prefix
    matching would count every worktree session against the parent repo too.

Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.

Verified: tsc and build clean; 46 new tests.
2026-07-29 17:12:00 +02:00

1725 lines
63 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,
FanoutLaunchOpts,
PermissionMode,
SyncState,
WorktreeState,
} from '../src/types.js'
import { el, relTime, statusText } from './preview-grid.js'
import { slugify, FANOUT_MAX_LANES } from './fanout.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
/** W5: fan ONE prompt across N branch/agent lanes of this repo (N worktrees, N
* Claude sessions on the same prompt, watched in the split-grid board). */
onFanout: (repoPath: string, repoName: string, opts: FanoutLaunchOpts) => 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
}
/** POST /projects/worktree — create one worktree on a new branch. Never throws;
* a network failure surfaces as { ok:false } so the caller can show an error.
* Shared (DRY) by renderNewWorktreeForm and TabApp.launchFanout (W5). */
export async function createWorktreeReq(
repoPath: string,
branch: string,
): Promise<CreateWorktreeResult> {
try {
const res = await fetch('/projects/worktree', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: repoPath, branch }),
})
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,
...(typeof d['path'] === 'string' ? { path: d['path'] } : {}),
...(typeof d['branch'] === 'string' ? { branch: d['branch'] } : {}),
...(typeof d['error'] === 'string' ? { error: d['error'] } : {}),
}
} catch {
return { ok: false, error: 'Failed to create worktree' }
}
}
/** 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' }
}
}
/** w6/G7: GET /projects/worktree/state — one worktree's branch/sync/dirty.
* null on any error; a row that cannot be probed simply shows no chips, which
* is the honest rendering of "unknown". */
export async function fetchWorktreeState(worktreePath: string): Promise<WorktreeState | null> {
try {
const res = await fetch(`/projects/worktree/state?path=${encodeURIComponent(worktreePath)}`)
if (!res.ok) return null
const raw: unknown = await res.json()
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['path'] !== 'string') return null
return {
path: o['path'],
...(typeof o['branch'] === 'string' ? { branch: o['branch'] } : {}),
...(typeof o['dirtyCount'] === 'number' ? { dirtyCount: o['dirtyCount'] } : {}),
...(o['sync'] !== null && typeof o['sync'] === 'object'
? { sync: o['sync'] as SyncState }
: {}),
}
} catch {
return null
}
}
/** w6/G3: POST /projects/git/fetch — refresh remote-tracking refs only (never a
* pull). The remote is chosen server-side; this body carries the repo and
* nothing else on purpose. Never throws. */
export async function fetchRepoReq(repoPath: string): Promise<WtActionResult> {
try {
const res = await fetch('/projects/git/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: repoPath }),
})
const data = (await res.json().catch(() => ({}))) as { error?: string }
if (!res.ok) return { ok: false, error: data.error ?? `Fetch failed (${res.status})` }
return { ok: true }
} catch {
return { ok: false, error: 'Fetch failed' }
}
}
/** 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
}
/** w6/G3: how old FETCH_HEAD may be before `behind` stops being trustworthy. */
export const FETCH_STALE_MS = 60 * 60 * 1000
export interface SyncBandOpts {
readonly dirtyCount?: number
readonly nowMs?: number
readonly onFetch?: () => void
}
/**
* w6/G3 — the project detail header's git state, as a band of chips.
*
* The hard rule this function exists to enforce: **only one state may read as
* "you're fine"** — even with upstream, and a fetch newer than FETCH_STALE_MS.
* `behind` is derived from `@{u}`, a locally cached ref that only a fetch moves,
* so a stale `behind: 0` is a guess, not a fact, and is marked as such. `ahead`
* never depends on a fetch and is therefore never marked stale.
*
* Returns null for a non-git project (nothing truthful to say).
*/
export function makeSyncBand(
sync: SyncState | undefined,
opts: SyncBandOpts = {},
): HTMLElement | null {
if (sync === undefined) return null
const band = el('div', 'proj-syncband')
const nowMs = opts.nowMs ?? Date.now()
const dirtyCount = opts.dirtyCount ?? 0
const isStale =
sync.lastFetchMs === undefined || nowMs - sync.lastFetchMs > FETCH_STALE_MS
const hasCounts = sync.ahead !== undefined || sync.behind !== undefined
const canFetch = sync.detached !== true
if (sync.detached === true) {
band.append(el('span', 'proj-sync-chip proj-sync-detached', 'detached HEAD'))
} else if (sync.upstream === undefined) {
// No upstream ⇒ nothing to compare against. NOT the same as "nothing to
// push", so this must never fall through to the in-sync branch below.
band.append(el('span', 'proj-sync-chip proj-sync-noupstream', 'no upstream'))
} else {
band.append(el('span', 'proj-sync-upstream', sync.upstream))
const ahead = sync.ahead ?? 0
const behind = sync.behind ?? 0
if (ahead === 0 && behind === 0 && !isStale) {
band.append(el('span', 'proj-sync-chip proj-sync-ok', '✓ in sync'))
} else {
if (ahead > 0 || hasCounts) {
const up = el('span', 'proj-sync-chip proj-sync-ahead', `${ahead}`)
up.title = `${ahead} commit(s) not pushed to ${sync.upstream}`
band.append(up)
}
if (behind > 0 || hasCounts) {
const down = el('span', 'proj-sync-chip proj-sync-behind', `${behind}`)
down.title = isStale
? `Last fetched ${relTime(sync.lastFetchMs ?? 0)} ago — fetch to trust this`
: `${behind} commit(s) on ${sync.upstream} not here`
if (isStale) down.classList.add('proj-sync-stale')
band.append(down)
}
}
}
if (isStale && sync.detached !== true) {
const flag = el(
'span',
'proj-sync-chip proj-sync-stale',
sync.lastFetchMs === undefined ? 'never fetched' : `fetched ${relTime(sync.lastFetchMs)} ago`,
)
flag.title = 'Behind-count is only as fresh as the last fetch'
band.append(flag)
}
if (dirtyCount > 0) {
const dirty = el('span', 'proj-sync-chip proj-sync-dirty', `${dirtyCount}`)
dirty.title = `${dirtyCount} uncommitted change(s)`
band.append(dirty)
}
const fetchBtn = el('button', 'proj-sync-fetch', 'Fetch') as HTMLButtonElement
fetchBtn.type = 'button'
fetchBtn.disabled = !canFetch
fetchBtn.title = canFetch
? 'Refresh remote-tracking refs (does not pull or merge)'
: 'Not available on a detached HEAD'
if (canFetch && opts.onFetch !== undefined) {
fetchBtn.addEventListener('click', () => opts.onFetch?.())
}
band.append(fetchBtn)
return band
}
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. */
/**
* w6/G7 — attribute each live session to the DEEPEST worktree containing its cwd.
*
* Plain prefix matching is wrong here and would be wrong in a way that reads as
* a fact: `.claude/worktrees/<name>` lives INSIDE the main checkout, so every
* worktree session would also be counted against the main repo. Returns a map of
* worktree path → count; sessions outside every worktree are dropped.
*/
export function countSessionsByWorktree(
sessions: readonly ProjectSessionRef[],
worktrees: readonly WorktreeInfo[],
): Map<string, number> {
const counts = new Map<string, number>()
for (const wt of worktrees) counts.set(wt.path, 0)
for (const s of sessions) {
if (s.exited || typeof s.cwd !== 'string') continue
let deepest: string | undefined
for (const wt of worktrees) {
if (!isInside(s.cwd, wt.path)) continue
if (deepest === undefined || wt.path.length > deepest.length) deepest = wt.path
}
if (deepest !== undefined) counts.set(deepest, (counts.get(deepest) ?? 0) + 1)
}
return counts
}
/** `child` is `parent` itself or sits under it — compared on path SEGMENTS so
* `/a/repo-old` is never treated as living inside `/a/repo`. */
function isInside(child: string, parent: string): boolean {
if (child === parent) return true
return child.startsWith(parent.endsWith('/') ? parent : `${parent}/`)
}
export interface WorktreeRowState {
/** Sync state for THIS worktree — the project's own checkout knows its state
* for free, other rows get theirs from the G7 probe. Still optional: an
* unprobed row must show nothing rather than a guess. */
readonly sync?: SyncState
readonly dirtyCount?: number
/** w6/G7: live sessions whose cwd sits inside this worktree. */
readonly sessionCount?: number
}
export function makeWorktreeRow(
wt: WorktreeInfo,
actions?: WorktreeRowActions,
state?: WorktreeRowState,
): 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'))
// w6/G5: the same vocabulary as the header band, so "no upstream" reads the
// same everywhere. A brand-new worktree branch tracks nothing, which is the
// common case here — it must say so rather than look settled.
if (state?.sync !== undefined) {
const sync = state.sync
if (sync.detached === true) {
row.append(el('span', 'proj-sync-chip proj-sync-detached', 'detached'))
} else if (sync.upstream === undefined) {
row.append(el('span', 'proj-sync-chip proj-sync-noupstream', 'no upstream'))
} else if ((sync.ahead ?? 0) > 0) {
row.append(el('span', 'proj-sync-chip proj-sync-ahead', `\u2191 ${sync.ahead}`))
}
}
if ((state?.dirtyCount ?? 0) > 0) {
row.append(el('span', 'proj-sync-chip proj-sync-dirty', `\u25cf ${state?.dirtyCount}`))
}
if ((state?.sessionCount ?? 0) > 0) {
const count = state?.sessionCount ?? 0
const chip = el('span', 'proj-wt-sessions', `\u25cf ${count}`)
chip.title = `${count} running session(s) in this worktree`
row.append(chip)
}
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
}
/** 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 result = await createWorktreeReq(detail.path, branch)
if (!result.ok) {
showError(result.error ?? 'Failed to create worktree')
return
}
hooks.onOpenProject(result.path ?? detail.path, result.branch ?? branch, 'claude\r')
} 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
}
/** Permission modes offered in the fan-out form (the high-risk 'auto' is left out;
* it is gated server-side and TabApp.launchFanout downgrades it anyway). */
const FANOUT_FORM_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan']
/**
* Render the "Fan out" form (W5) — a sibling of renderNewWorktreeForm. Fans ONE
* prompt across N lanes: prompt textarea, lane-count stepper (2..maxLanes),
* branch-base input (auto-filled from the prompt slug until edited), a permission-
* mode select, and a submit that calls hooks.onFanout. Errors render via
* textContent only (SEC-L3/H6). `maxLanes` mirrors the server MAX_FANOUT_LANES.
*/
export function renderFanoutForm(
detail: ProjectDetail,
hooks: ProjectsHooks,
maxLanes: number = FANOUT_MAX_LANES,
): HTMLElement {
const cap = Math.max(2, Math.floor(maxLanes))
const form = el('div', 'proj-fanout-form')
const prompt = document.createElement('textarea')
prompt.className = 'proj-fanout-prompt'
prompt.placeholder = 'One task, fanned across N lanes (e.g. "add dark mode toggle")'
prompt.setAttribute('aria-label', 'Fan-out task prompt')
prompt.rows = 2
prompt.maxLength = 4000
const row = el('div', 'proj-fanout-row')
const lanes = document.createElement('input')
lanes.type = 'number'
lanes.className = 'proj-fanout-lanes'
lanes.min = '2'
lanes.max = String(cap)
lanes.value = String(Math.max(2, Math.min(3, cap)))
lanes.setAttribute('aria-label', 'Number of lanes')
const branch = document.createElement('input')
branch.type = 'text'
branch.className = 'proj-fanout-branch'
branch.placeholder = 'branch base'
branch.setAttribute('aria-label', 'Branch base name')
branch.maxLength = 200
const mode = document.createElement('select')
mode.className = 'proj-fanout-mode'
mode.setAttribute('aria-label', 'Permission mode')
for (const m of FANOUT_FORM_MODES) {
const opt = document.createElement('option')
opt.value = m
opt.textContent = m
mode.append(opt)
}
row.append(lanes, branch, mode)
const errorEl = el('div', 'proj-fanout-error')
errorEl.style.display = 'none'
const submitBtn = el('button', 'proj-fanout-submit') as HTMLButtonElement
form.append(prompt, row, errorEl, submitBtn)
let branchTouched = false
function laneCount(): number {
const n = Math.floor(Number(lanes.value))
if (!Number.isFinite(n)) return 2
return Math.max(2, Math.min(cap, n))
}
function refreshSubmit(): void {
const n = laneCount()
submitBtn.textContent = `⑃ Fan out ${n} lanes`
submitBtn.disabled = prompt.value.trim() === ''
}
function showError(msg: string): void {
errorEl.textContent = msg // SEC-L3/H6: textContent, never innerHTML
errorEl.style.display = ''
}
function hideError(): void {
errorEl.textContent = ''
errorEl.style.display = 'none'
}
prompt.addEventListener('input', () => {
if (!branchTouched) branch.value = slugify(prompt.value)
if (errorEl.style.display !== 'none') hideError()
refreshSubmit()
})
branch.addEventListener('input', () => {
branchTouched = true
})
lanes.addEventListener('input', refreshSubmit)
submitBtn.addEventListener('click', () => {
const text = prompt.value.trim()
if (text === '') {
showError('Enter a task prompt to fan out.')
return
}
const branchBase = (branch.value.trim() || slugify(text)).trim()
const branchErr = validateBranchNameClient(branchBase)
if (branchErr !== null) {
showError(branchErr)
return
}
hideError()
hooks.onFanout(detail.path, detail.name, {
prompt: text,
lanes: laneCount(),
branchBase,
mode: mode.value as PermissionMode,
})
})
refreshSubmit()
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
// w6/G3: refresh remote-tracking refs so the header's `behind` stops being a
// stale guess. Optional for the same reason as the W4 pair above.
onFetch?: () => void
// w6/G7: per-worktree state already probed by mountProjects, keyed by worktree
// path. Absent entries render no chips — unknown is shown as nothing, not zero.
worktreeStates?: ReadonlyMap<string, WorktreeState>
}
/** 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))
// w6/G3: the sync band carries the dirty count, so the bare dot is only a
// fallback for the pre-G3 shape (no sync ⇒ non-git, or an older server).
const syncBand = makeSyncBand(detail.sync, {
...(detail.dirtyCount !== undefined ? { dirtyCount: detail.dirtyCount } : {}),
...(cb.onFetch !== undefined ? { onFetch: cb.onFetch } : {}),
})
if (syncBand === null && 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))
if (syncBand !== null) root.append(syncBand)
// 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')
// w6/G5: always "Worktrees". With one worktree per session the count is the
// point, and a heading that renames itself at n=2 hides that this is a list.
wtHead.append(
el(
'div',
'proj-section-title',
detail.worktrees.length > 0 ? `Worktrees (${detail.worktrees.length})` : 'Worktrees',
),
)
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')
const sessionCounts = countSessionsByWorktree(detail.sessions, detail.worktrees)
for (const wt of detail.worktrees) {
// The current worktree's state came free with the detail; every other row
// waits for its G7 probe and shows nothing until it lands.
const probed = cb.worktreeStates?.get(wt.path)
const own =
wt.isCurrent === true
? {
...(detail.sync !== undefined ? { sync: detail.sync } : {}),
...(detail.dirtyCount !== undefined ? { dirtyCount: detail.dirtyCount } : {}),
}
: {}
const state: WorktreeRowState = {
...(probed?.sync !== undefined ? { sync: probed.sync } : {}),
...(probed?.dirtyCount !== undefined ? { dirtyCount: probed.dirtyCount } : {}),
...own,
sessionCount: sessionCounts.get(wt.path) ?? 0,
}
list.append(makeWorktreeRow(wt, wtActions, state))
}
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))
}
// W5: Fan-out form (git repos only) — one prompt across N branch/agent lanes.
if (detail.isGit) {
root.append(el('div', 'proj-section-title', 'Fan out'))
root.append(renderFanoutForm(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'
}
/** w6/G6: the payload behind the last detail render, so an unchanged 5 s tick
* can be dropped. Re-rendering rebuilds the whole subtree, which re-mounts the
* commit log and the PR chip — i.e. it re-fetches `/projects/log` (two more
* `git` spawns) every 5 s to redraw pixel-identical rows. Skipping also keeps
* the diff viewer's scroll position and avoids the flicker. */
let lastDetailJson: string | null = null
function openDetail(p: string): void {
view = 'detail'
detailPath = p
// w6/G6: a new project must always paint, even if its payload happens to
// serialise identically to the one we rendered last.
lastDetailJson = null
worktreeStates = new Map()
worktreeProbeFor = null
applyView()
void refresh()
}
function closeDetail(): void {
view = 'grid'
detailPath = null
lastDetailJson = 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()
})
}
/** w6/G3: fetch → refresh. In flight, `fetching` blocks a second click so a
* slow network can't queue a pile of fetches behind one button. On failure
* the detail is still re-rendered: lastFetchMs is unchanged, so the band
* correctly keeps showing "stale" rather than pretending it refreshed. */
let fetching = false
function fetchAndRefresh(): void {
if (detailPath === null || fetching) return
fetching = true
void fetchRepoReq(detailPath)
.then((res) => {
if (!res.ok) showWtActionError(res.error ?? 'Fetch failed')
if (view === 'detail') void refresh()
})
.finally(() => {
fetching = false
})
}
/** 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,
onFetch: fetchAndRefresh,
worktreeStates,
},
detailTimelineHandles,
),
)
}
/** w6/G7: probed worktree state, keyed by worktree path. Filled ONCE per opened
* project rather than on every 5 s tick — one probe costs two `git` spawns per
* worktree, and a row's branch/ahead only moves when the user does something,
* which lands here anyway via the explicit refresh after fetch/create/remove. */
let worktreeStates = new Map<string, WorktreeState>()
let worktreeProbeFor: string | null = null
async function probeWorktrees(detail: ProjectDetail): Promise<void> {
// The current worktree already reported itself inside `detail` — never spend
// a probe on it.
const targets = detail.worktrees.filter((w) => w.isCurrent !== true).map((w) => w.path)
if (targets.length === 0) return
const probed = await Promise.all(targets.map((wt) => fetchWorktreeState(wt)))
// A different project may have been opened while these were in flight.
if (detailPath !== detail.path) return
const next = new Map(worktreeStates)
for (const state of probed) {
if (state !== null) next.set(state.path, state)
}
worktreeStates = next
lastDetailJson = null // force one re-render so the new chips appear
if (view === 'detail') void refresh()
}
async function refresh(): Promise<void> {
if (view === 'detail' && detailPath !== null) {
const detail = await fetchProjectDetail(detailPath)
const json = JSON.stringify(detail)
if (json === lastDetailJson) return
lastDetailJson = json
renderDetail(detail)
if (detail !== null && worktreeProbeFor !== detail.path) {
worktreeProbeFor = detail.path
void probeWorktrees(detail)
}
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(() => {
// w6/G6: a backgrounded tab (or a phone with the screen off) kept
// spawning `git` on the host every 5 s for a view nobody could see.
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
if (root.style.display !== 'none') void refresh()
}, REFRESH_MS)
}
} else {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
},
refresh(): void {
void refresh()
},
}
}