Files
web-terminal/public/projects.ts
Yaojia Wang 9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00

1407 lines
50 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,
} 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' }
}
}
/** Remove-worktree confirm flow (exported so mountProjects and tests share the
* real code). Confirms once; on a 409 dirty-tree refusal, confirms a SECOND
* time before retrying with force — no single-click data loss. `onError`
* receives a safe message (rendered via textContent by the caller, SEC-L3/H6).
* Returns 'removed' (caller refreshes), 'cancelled', or 'error'. */
export async function confirmAndRemoveWorktree(
repoPath: string,
worktreePath: string,
onError: (msg: string) => void,
): Promise<'removed' | 'cancelled' | 'error'> {
if (!window.confirm(`Remove worktree at ${worktreePath}? This deletes the working tree.`)) {
return 'cancelled'
}
const first = await removeWorktreeReq(repoPath, worktreePath, false)
if (first.ok) return 'removed'
if (first.status === 409) {
if (!window.confirm('Uncommitted changes will be lost. Force-remove?')) return 'cancelled'
const forced = await removeWorktreeReq(repoPath, worktreePath, true)
if (forced.ok) return 'removed'
onError(forced.error ?? 'Failed to remove worktree')
return 'error'
}
onError(first.error ?? 'Failed to remove worktree')
return 'error'
}
/** Prune confirm flow (exported; shared by mountProjects and tests). Returns
* 'pruned' (caller refreshes), 'cancelled', or 'error'. */
export async function confirmAndPruneWorktrees(
repoPath: string,
onError: (msg: string) => void,
): Promise<'pruned' | 'cancelled' | 'error'> {
if (!window.confirm('Prune worktrees whose folders are gone?')) return 'cancelled'
const res = await pruneWorktreesReq(repoPath)
if (res.ok) return 'pruned'
onError(res.error ?? 'Failed to prune worktrees')
return 'error'
}
/* ── Status helpers ──────────────────────────────────────────────────────── */
function sessionDotClass(status: ClaudeStatus): string {
return `proj-session-dot proj-dot-${status}`
}
/* ── Card sub-elements ───────────────────────────────────────────────────── */
function makeSessionRow(
sess: { id: string; title?: string; status: ClaudeStatus; clientCount: number },
onEnterSession: (id: string) => void,
onKill?: (id: string) => void,
): HTMLElement {
const row = el('div', 'proj-session-row')
row.setAttribute('role', 'button')
row.tabIndex = 0
const dot = el('span', sessionDotClass(sess.status))
const title = el('span', 'proj-session-title', sess.title ?? 'claude')
const badge = el('span', 'proj-session-badge', `👁 ${sess.clientCount}`)
row.append(dot, title, badge)
if (onKill) {
const kill = el('button', 'proj-session-kill', '✕')
kill.title = 'Kill this session'
kill.setAttribute('aria-label', 'Kill session')
// Don't let the kill button trigger the row's enter handler.
kill.addEventListener('pointerdown', (e) => e.stopPropagation())
kill.addEventListener('click', (e) => {
e.stopPropagation()
onKill(sess.id)
})
row.append(kill)
}
row.addEventListener('click', () => onEnterSession(sess.id))
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onEnterSession(sess.id)
}
})
return row
}
/** Ask the host to open this project in the editor (POST /open-in-editor → `code <path>`). */
async function openProjectInEditor(repoPath: string): Promise<void> {
try {
const res = await fetch('/open-in-editor', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: repoPath }),
})
if (!res.ok) console.error('[projects] open in editor failed:', res.status)
} catch (err) {
console.error('[projects] open in editor error:', err)
}
}
interface LauncherOpts {
/** Highlight the button (a matching session is running). */
active?: boolean
/** Optional count badge (only shown when > 1). */
count?: number
}
/** One launcher button: brand logo (currentColor) + caption; colour set via CSS. */
function makeLauncher(
logo: string,
label: string,
cls: string,
title: string,
onClick: () => void,
opts?: LauncherOpts,
): HTMLElement {
const active = opts?.active === true
const btn = el('button', `proj-launch ${cls}${active ? ' proj-launch-active' : ''}`)
btn.title = title
btn.setAttribute('aria-label', title)
btn.innerHTML = logo // static, trusted brand SVG — no user data
btn.append(el('span', 'proj-launch-cap', label))
if (active && opts?.count !== undefined && opts.count > 1) {
btn.append(el('span', 'proj-launch-badge', String(opts.count)))
}
btn.addEventListener('click', (e) => {
e.stopPropagation()
onClick()
})
return btn
}
/** Count running Claude sessions in a project. A session counts as "Claude" once
* Claude Code hooks report a status (working/waiting/idle); plain shells and
* codex sessions stay 'unknown'. Used to highlight the Claude launcher. */
function activeClaudeCount(project: ProjectInfo): number {
return project.sessions.filter((s) => !s.exited && s.status !== 'unknown').length
}
/** Launcher row for a card: Claude · Codex (new terminal sessions) · VS Code (host editor). */
function makeLauncherRow(project: ProjectInfo, hooks: ProjectsHooks): HTMLElement {
const claude = activeClaudeCount(project)
const claudeTitle =
claude > 0
? `${claude} active Claude session${claude > 1 ? 's' : ''} in ${project.name} — start another`
: `Start Claude in ${project.name}`
const row = el('div', 'proj-launchers')
row.append(
makeLauncher(
CLAUDE_LOGO,
'Claude',
'proj-launch-claude',
claudeTitle,
() => hooks.onOpenProject(project.path, project.name, 'claude\r'),
{ active: claude > 0, count: claude },
),
makeLauncher(CODEX_LOGO, 'Codex', 'proj-launch-codex', `Start Codex in ${project.name}`, () =>
hooks.onOpenProject(project.path, project.name, 'codex\r'),
),
makeLauncher(VSCODE_LOGO, 'Code', 'proj-launch-vscode', `Open ${project.name} in VS Code`, () =>
void openProjectInEditor(project.path),
),
)
return row
}
/** W3(a) sync chip: `↑ahead ↓behind` vs upstream, with the last-commit time as a
* tooltip. Returns null when the repo is in sync (ahead=behind=0/undefined) so
* the chip only appears when there is drift worth showing. */
export function makeSyncChip(project: ProjectInfo): HTMLElement | null {
const ahead = project.ahead ?? 0
const behind = project.behind ?? 0
if (ahead <= 0 && behind <= 0) return null
const parts: string[] = []
if (ahead > 0) parts.push(`${ahead}`)
if (behind > 0) parts.push(`${behind}`)
const chip = el('span', 'proj-sync', parts.join(' '))
const tips: string[] = []
if (ahead > 0) tips.push(`${ahead} ahead of upstream`)
if (behind > 0) tips.push(`${behind} behind upstream`)
if (project.lastCommitMs !== undefined) tips.push(`last commit ${relTime(project.lastCommitMs)} ago`)
chip.title = tips.join(' · ')
return chip
}
export function makeProjectCard(
project: ProjectInfo,
favs: ReadonlySet<string>,
hooks: ProjectsHooks,
onToggleFav: (path: string) => void,
onOpenDetail?: (path: string) => void,
onKillSession?: (id: string) => void,
displayName?: string,
): HTMLElement {
const card = el('div', 'proj-card')
// ── Header ────────────────────────────────────────────────────────────────
const header = el('div', 'proj-card-head')
const isFav = favs.has(project.path)
const favBtn = el('button', isFav ? 'proj-fav on' : 'proj-fav')
favBtn.textContent = '★'
favBtn.title = isFav ? 'Remove from favourites' : 'Add to favourites'
favBtn.addEventListener('click', (e) => {
e.stopPropagation()
onToggleFav(project.path)
})
// Visible label may be the namespace tail (e.g. "Payment"); launch/detail still
// use the full project.name so tab titles and lookups stay unambiguous.
const name = el('span', 'proj-name', displayName ?? project.name)
name.title = project.name
if (onOpenDetail) {
name.classList.add('proj-name-link')
name.setAttribute('role', 'button')
name.tabIndex = 0
name.title = `View ${project.name} details`
name.addEventListener('click', () => onOpenDetail(project.path))
name.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenDetail(project.path)
}
})
}
header.append(favBtn, name)
if (project.branch) {
const branch = el('span', 'proj-branch', project.branch)
header.append(branch)
}
// W3(a): ahead/behind-vs-upstream sync chip (only when there's drift).
const syncChip = makeSyncChip(project)
if (syncChip !== null) header.append(syncChip)
if (project.dirty === true) {
const dirty = el('span', 'proj-dirty', '●')
dirty.title = 'Uncommitted changes'
header.append(dirty)
}
card.append(header)
// ── Meta line ─────────────────────────────────────────────────────────────
if (project.lastActiveMs !== undefined) {
const meta = el('div', 'proj-meta', `active ${relTime(project.lastActiveMs)} ago`)
card.append(meta)
}
// ── Sessions area (1:N) — list any running sessions above the launchers ─────
const runningSessions = project.sessions.filter((s) => !s.exited)
if (runningSessions.length > 0) {
const sessionsList = el('div', 'proj-sessions-list')
for (const sess of runningSessions) {
sessionsList.append(makeSessionRow(sess, hooks.onEnterSession, onKillSession))
}
card.append(sessionsList)
}
// ── Launchers (Claude · Codex · VS Code) — always available ─────────────────
card.append(makeLauncherRow(project, hooks))
return card
}
/* ── Detail view (branch / worktrees + active sessions) ─────────────────────── */
/** Actions a worktree row can offer (W4). Present → non-main/non-locked rows get
* a Remove (✕) button; a locked row keeps its tag but no button (unlock first). */
export interface WorktreeRowActions {
onRemove: (wt: WorktreeInfo) => void
}
/** One worktree row: branch (or detached@head) + main/current/locked tags + path,
* plus an optional Remove button (W4) for removable (non-main, non-locked) rows. */
export function makeWorktreeRow(wt: WorktreeInfo, actions?: WorktreeRowActions): HTMLElement {
const row = el('div', 'proj-wt-row')
const label = wt.branch ?? (wt.head ? `detached @ ${wt.head}` : 'detached')
row.append(el('span', 'proj-wt-branch', label))
if (wt.isMain) row.append(el('span', 'proj-wt-tag', 'main'))
if (wt.isCurrent) row.append(el('span', 'proj-wt-tag proj-wt-tag-current', 'current'))
if (wt.locked) row.append(el('span', 'proj-wt-tag', 'locked'))
if (wt.prunable) row.append(el('span', 'proj-wt-tag', 'prunable'))
row.append(el('span', 'proj-wt-path', wt.path))
// W4: only linked, unlocked worktrees are removable. The main worktree (the
// repo itself) and locked worktrees never get a button — matches the server's
// 400/409 refusals so the UI can't invite an action git will reject.
if (actions !== undefined && !wt.isMain && wt.locked !== true) {
const remove = el('button', 'proj-wt-remove', '✕')
remove.title = 'Remove this worktree'
remove.setAttribute('aria-label', 'Remove worktree')
remove.addEventListener('click', () => actions.onRemove(wt))
row.append(remove)
}
return row
}
/** One detailed session row: status + title + metadata + Open / Kill actions. */
function makeDetailSessionRow(
s: ProjectSessionRef,
onEnter: (id: string) => void,
onKill: (id: string) => void,
): HTMLElement {
const row = el('div', 'proj-dsession')
const dot = el('span', sessionDotClass(s.status))
const main = el('div', 'proj-dsession-main')
main.append(el('span', 'proj-dsession-title', s.title ?? 'session'))
const parts: string[] = []
if (s.status !== 'unknown') parts.push(statusText(s.status))
parts.push(`👁 ${s.clientCount}`)
parts.push(`started ${relTime(s.createdAt)} ago`)
main.append(el('span', 'proj-dsession-meta', parts.join(' · ')))
const open = el('button', 'proj-dsession-open', 'Open')
open.addEventListener('click', () => onEnter(s.id))
const kill = el('button', 'proj-dsession-kill', '✕')
kill.title = 'Kill this session'
kill.setAttribute('aria-label', 'Kill session')
kill.addEventListener('click', () => onKill(s.id))
row.append(dot, main, open, kill)
return row
}
/** Returns null if `branch` is a valid git branch name, or an error message (B3). Never throws. */
export function validateBranchNameClient(branch: string): string | null {
if (!branch) return 'Branch name cannot be empty'
if (branch.length > 250) return 'Branch name is too long (max 250 characters)'
if (/[\x00-\x20\x7f]/.test(branch)) return 'Branch name cannot contain spaces or control characters'
if (branch.startsWith('-')) return 'Branch name cannot start with a hyphen'
if (branch.includes('..')) return 'Branch name cannot contain ".."'
if (branch.endsWith('.lock')) return 'Branch name cannot end with ".lock"'
if (/[~^:?*\[\\]/.test(branch)) return 'Branch name contains invalid characters'
if (branch.includes('@{')) return 'Branch name cannot contain "@{"'
if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) {
return 'Branch name has invalid slash usage'
}
return null
}
/** 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
}
/** Render the project detail view (exported for unit tests). `timelineCollector` receives A4 handles for re-render disposal. */
export function renderProjectDetail(
detail: ProjectDetail | null,
hooks: ProjectsHooks,
cb: DetailCallbacks,
timelineCollector?: TimelineHandle[],
): HTMLElement {
const root = el('div', 'proj-detail-inner')
const localTimelines: TimelineHandle[] = []
const diffRef: { h: DiffViewerHandle | null } = { h: null }
const prRef: { h: PrChipHandle | null } = { h: null }
const logRef: { h: GitLogHandle | null } = { h: null }
const back = el('button', 'proj-back', '← All projects')
back.addEventListener('click', () => {
diffRef.h?.destroy()
diffRef.h = null
prRef.h?.destroy()
prRef.h = null
logRef.h?.destroy()
logRef.h = null
for (const handle of localTimelines) handle.dispose()
cb.onBack()
})
root.append(back)
if (detail === null) {
root.append(el('div', 'proj-empty', 'Project not found.'))
return root
}
// Header: name + branch + dirty + path
const head = el('div', 'proj-detail-head')
head.append(el('h2', 'proj-detail-name', detail.name))
if (detail.branch) head.append(el('span', 'proj-branch', detail.branch))
if (detail.dirty === true) {
const d = el('span', 'proj-dirty', '●')
d.title = 'Uncommitted changes'
head.append(d)
}
// W3: PR + CI status chip (git repos only). Read-only side-channel via `gh`;
// self-degrades (gh missing / unauthed / no PR) so it never blocks the header.
if (detail.isGit) {
const prHost = el('span', 'proj-pr-host')
head.append(prHost)
prRef.h = mountPrChip(prHost, detail.path)
}
root.append(head)
root.append(el('div', 'proj-detail-path', detail.path))
// B1: View Diff (git repos only) — SEC-H4 enforced inside mountDiffViewer.
// FR-B1.9: offer this repo's worktree branches + current branch as diff bases.
if (detail.isGit) {
const bases = Array.from(
new Set(
[...detail.worktrees.map((w) => w.branch), detail.branch].filter(
(b): b is string => typeof b === 'string' && b !== '',
),
),
)
root.append(buildDiffSection(detail.path, diffRef, bases))
}
// W3(d): Recent commits (git repos only) — read-only, inert-text rows.
if (detail.isGit) {
root.append(el('div', 'proj-section-title', 'Recent commits'))
const logHost = el('div', 'proj-commitlog')
root.append(logHost)
logRef.h = mountGitLog(logHost, detail.path)
}
// Branch / worktrees — header carries a "Prune stale worktrees" button (W4)
// when any worktree is prunable and a prune callback is wired.
const wtHead = el('div', 'proj-section-row')
wtHead.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch'))
if (cb.onPruneWorktrees !== undefined && detail.worktrees.some((w) => w.prunable === true)) {
const pruneBtn = el('button', 'proj-wt-prune', 'Prune stale worktrees')
pruneBtn.title = 'Remove worktrees whose folders are gone'
pruneBtn.addEventListener('click', () => cb.onPruneWorktrees?.())
wtHead.append(pruneBtn)
}
root.append(wtHead)
// W4: a shared, initially-hidden error line for remove/prune failures. Written
// via textContent only by mountProjects (SEC-L3/H6); persists until the next
// refresh rebuilds this detail view.
const wtError = el('div', 'proj-wt-actions-error')
wtError.style.display = 'none'
root.append(wtError)
if (!detail.isGit) {
root.append(el('div', 'proj-empty', 'Not a git repository.'))
} else if (detail.worktrees.length === 0) {
root.append(
el('div', 'proj-empty', detail.branch ? `On branch ${detail.branch}` : 'No worktree info.'),
)
} else {
const wtActions: WorktreeRowActions | undefined =
cb.onRemoveWorktree !== undefined
? { onRemove: (wt) => cb.onRemoveWorktree?.(wt.path) }
: undefined
const list = el('div', 'proj-wt-list')
for (const wt of detail.worktrees) list.append(makeWorktreeRow(wt, wtActions))
root.append(list)
}
// B3: New Worktree form (git repos only)
if (detail.isGit) {
root.append(el('div', 'proj-section-title', 'New Worktree'))
root.append(renderNewWorktreeForm(detail, hooks))
}
// 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'
}
function openDetail(p: string): void {
view = 'detail'
detailPath = p
applyView()
void refresh()
}
function closeDetail(): void {
view = 'grid'
detailPath = null
applyView()
void refresh()
}
function killAndRefresh(id: string): void {
void killSession(id).then(() => void refresh())
}
/** Render a remove/prune failure into the detail view's error line (textContent
* only, SEC-L3/H6). No-op if the element isn't present (e.g. mid-transition). */
function showWtActionError(msg: string): void {
const errEl = detailEl.querySelector('.proj-wt-actions-error')
if (errEl instanceof HTMLElement) {
errEl.textContent = msg
errEl.style.display = ''
}
}
/** W4: confirm → (force?) → refresh flow for removing one worktree. */
function removeWorktreeAndRefresh(worktreePath: string): void {
if (detailPath === null) return
void confirmAndRemoveWorktree(detailPath, worktreePath, showWtActionError).then((outcome) => {
if (outcome === 'removed' && view === 'detail') void refresh()
})
}
/** W4: confirm → prune → refresh flow for stale worktrees. */
function pruneWorktreesAndRefresh(): void {
if (detailPath === null) return
void confirmAndPruneWorktrees(detailPath, showWtActionError).then((outcome) => {
if (outcome === 'pruned' && view === 'detail') void refresh()
})
}
/** The card grid for one group's projects (tail labels inside namespace groups). */
function renderCards(group: ProjectGroup): HTMLElement {
const cards = el('div', 'proj-grid-cards')
for (const p of group.projects) {
cards.append(
makeProjectCard(
p,
favs,
hooks,
onToggleFav,
openDetail,
killAndRefresh,
displayLabel(p.name, group.key),
),
)
}
return cards
}
function renderGrid(): void {
const query = searchInput.value
const filtered = filterProjects(allProjects, query)
const searching = query.trim() !== ''
// Preserve scroll across the 5 s auto-refresh rebuild.
const prevScroll = root.scrollTop
grid.replaceChildren()
if (filtered.length === 0) {
const msg =
allProjects.length === 0
? 'No projects found. Check PROJECT_ROOTS configuration.'
: 'No projects match your search.'
grid.append(el('div', 'proj-empty', msg))
return
}
for (const group of groupProjects(filtered, favs)) {
if (group.kind === 'flat') {
grid.append(renderCards(group)) // no header — plain grid fallback
continue
}
const collapsible = group.kind === 'namespace' || group.kind === 'other'
// While searching, force every matching section open so results are never
// hidden behind a collapsed caret.
const isCollapsed = collapsible && !searching && collapsed[group.key] === true
const section = el('div', `proj-group${isCollapsed ? ' collapsed' : ''}`)
section.append(
makeGroupHeader(group, isCollapsed, collapsible, () => toggleCollapsed(group.key)),
)
if (!isCollapsed) section.append(renderCards(group))
grid.append(section)
}
root.scrollTop = prevScroll
}
function renderDetail(detail: ProjectDetail | null): void {
// Dispose timelines from the previous render before replacing DOM (A4 re-render)
for (const h of detailTimelineHandles) h.dispose()
detailTimelineHandles = []
detailEl.replaceChildren(
renderProjectDetail(
detail,
hooks,
{
onBack: closeDetail,
onKill: (id) => {
void killSession(id).then(() => {
if (view === 'detail') void refresh()
})
},
onRemoveWorktree: removeWorktreeAndRefresh,
onPruneWorktrees: pruneWorktreesAndRefresh,
},
detailTimelineHandles,
),
)
}
async function refresh(): Promise<void> {
if (view === 'detail' && detailPath !== null) {
renderDetail(await fetchProjectDetail(detailPath))
return
}
allProjects = await fetchProjects()
renderGrid()
}
/** Load cross-device prefs once, then refresh. Prefs stay in memory afterward
* (the 5 s tick only re-fetches projects, so it never clobbers local edits). */
async function init(): Promise<void> {
if (!prefsLoaded) {
const prefs = await loadPrefs()
favs = new Set(prefs.favourites)
collapsed = { ...prefs.collapsed }
prefsLoaded = true
}
await refresh()
}
searchInput.addEventListener('input', () => renderGrid())
return {
setVisible(v: boolean): void {
root.style.display = v ? 'block' : 'none'
if (v) {
// Re-entering the chooser always lands on the grid (never a stale detail).
view = 'grid'
detailPath = null
applyView()
void init()
if (timer === null) {
timer = setInterval(() => {
if (root.style.display !== 'none') void refresh()
}, REFRESH_MS)
}
} else {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
},
refresh(): void {
void refresh()
},
}
}