/** * 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, } from '../src/types.js' import { el, relTime, statusText } from './preview-grid.js' import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js' /* ── Constants ───────────────────────────────────────────────────────────── */ const FAV_KEY = 'web-terminal:proj-favs' const REFRESH_MS = 5000 /* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */ export interface ProjectsHooks { /** Spawn a new terminal session in the repo running `cmd` (default: claude). */ onOpenProject: (repoPath: string, repoName: string, cmd?: string) => void /** Enter an already-running session by id. */ onEnterSession: (id: string) => void } export interface ProjectsPanel { setVisible(v: boolean): void refresh(): void } /* ── Pure helpers (exported for unit tests) ──────────────────────────────── */ /** * Filter projects by name or path substring (case-insensitive). * Returns a new array; never mutates the input. */ export function filterProjects(projects: readonly ProjectInfo[], query: string): ProjectInfo[] { const trimmed = query.trim() if (!trimmed) return [...projects] const lower = trimmed.toLowerCase() return projects.filter( (p) => p.name.toLowerCase().includes(lower) || p.path.toLowerCase().includes(lower), ) } /** * Sort projects: favourites first, then by lastActiveMs descending. * Returns a new array; never mutates the input. */ export function sortProjects( projects: readonly ProjectInfo[], favs: ReadonlySet, ): ProjectInfo[] { return [...projects].sort((a, b) => { const aFav = favs.has(a.path) ? 0 : 1 const bFav = favs.has(b.path) ? 0 : 1 if (aFav !== bFav) return aFav - bFav return (b.lastActiveMs ?? 0) - (a.lastActiveMs ?? 0) }) } /** Load persisted favourite project paths from localStorage. */ export function getFavs(): Set { try { const raw = localStorage.getItem(FAV_KEY) if (!raw) return new Set() const parsed: unknown = JSON.parse(raw) // Never trust persisted data: keep only string entries (corrupt/tampered // localStorage could otherwise poison the Set used for path matching). if (!Array.isArray(parsed)) return new Set() return new Set(parsed.filter((x): x is string => typeof x === 'string')) } catch { return new Set() } } /** Persist a set of favourite project paths to localStorage. */ export function saveFavs(favs: ReadonlySet): void { try { localStorage.setItem(FAV_KEY, JSON.stringify([...favs])) } catch { // localStorage unavailable — run without persistence } } /** * Immutable toggle: add if absent, remove if present. * Returns a new Set; never mutates the original. */ export function toggleFav(favs: ReadonlySet, path: string): Set { const next = new Set(favs) if (next.has(path)) { next.delete(path) } else { next.add(path) } return next } /* ── Fetch ───────────────────────────────────────────────────────────────── */ /** Coerce one untrusted /projects element into a safe ProjectInfo, or null. * Guarantees `sessions` is always an array so card rendering never throws on a * malformed response (never trust external data — API responses included). */ export function normalizeProject(p: unknown): ProjectInfo | null { if (p === null || typeof p !== 'object') return null const o = p as Record if (typeof o['name'] !== 'string' || typeof o['path'] !== 'string') return null const sessions = Array.isArray(o['sessions']) ? (o['sessions'] as ProjectSessionRef[]) : [] return { name: o['name'], path: o['path'], isGit: o['isGit'] === true, ...(typeof o['branch'] === 'string' ? { branch: o['branch'] } : {}), ...(typeof o['dirty'] === 'boolean' ? { dirty: o['dirty'] } : {}), ...(typeof o['lastActiveMs'] === 'number' ? { lastActiveMs: o['lastActiveMs'] } : {}), sessions, } } async function fetchProjects(): Promise { try { const res = await fetch('/projects') const data: unknown = await res.json() if (!Array.isArray(data)) return [] return data.map(normalizeProject).filter((x): x is ProjectInfo => x !== null) } catch { return [] } } /** Fetch one project's detail (branch/worktrees + sessions). null on any error. */ async function fetchProjectDetail(projectPath: string): Promise { try { const res = await fetch(`/projects/detail?path=${encodeURIComponent(projectPath)}`) if (!res.ok) return null return (await res.json()) as ProjectDetail } catch { return null } } /** Kill a running session (manage-page DELETE; same-origin → Origin guard passes). */ async function killSession(id: string): Promise { try { await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) } catch { // best-effort — the refresh that follows will reflect the real state } } /* ── 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, ): 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) row.addEventListener('click', () => onEnterSession(sess.id)) row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onEnterSession(sess.id) } }) return row } /** Ask the host to open this project in the editor (POST /open-in-editor → `code `). */ async function openProjectInEditor(repoPath: string): Promise { try { const res = await fetch('/open-in-editor', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: repoPath }), }) if (!res.ok) console.error('[projects] open in editor failed:', res.status) } catch (err) { console.error('[projects] open in editor error:', err) } } interface LauncherOpts { /** Highlight the button (a matching session is running). */ active?: boolean /** Optional count badge (only shown when > 1). */ count?: number } /** One launcher button: brand logo (currentColor) + caption; colour set via CSS. */ function makeLauncher( logo: string, label: string, cls: string, title: string, onClick: () => void, opts?: LauncherOpts, ): HTMLElement { const active = opts?.active === true const btn = el('button', `proj-launch ${cls}${active ? ' proj-launch-active' : ''}`) btn.title = title btn.setAttribute('aria-label', title) btn.innerHTML = logo // static, trusted brand SVG — no user data btn.append(el('span', 'proj-launch-cap', label)) if (active && opts?.count !== undefined && opts.count > 1) { btn.append(el('span', 'proj-launch-badge', String(opts.count))) } btn.addEventListener('click', (e) => { e.stopPropagation() onClick() }) return btn } /** Count running Claude sessions in a project. A session counts as "Claude" once * Claude Code hooks report a status (working/waiting/idle); plain shells and * codex sessions stay 'unknown'. Used to highlight the Claude launcher. */ function activeClaudeCount(project: ProjectInfo): number { return project.sessions.filter((s) => !s.exited && s.status !== 'unknown').length } /** Launcher row for a card: Claude · Codex (new terminal sessions) · VS Code (host editor). */ function makeLauncherRow(project: ProjectInfo, hooks: ProjectsHooks): HTMLElement { const claude = activeClaudeCount(project) const claudeTitle = claude > 0 ? `${claude} active Claude session${claude > 1 ? 's' : ''} in ${project.name} — start another` : `Start Claude in ${project.name}` const row = el('div', 'proj-launchers') row.append( makeLauncher( CLAUDE_LOGO, 'Claude', 'proj-launch-claude', claudeTitle, () => hooks.onOpenProject(project.path, project.name, 'claude\r'), { active: claude > 0, count: claude }, ), makeLauncher(CODEX_LOGO, 'Codex', 'proj-launch-codex', `Start Codex in ${project.name}`, () => hooks.onOpenProject(project.path, project.name, 'codex\r'), ), makeLauncher(VSCODE_LOGO, 'Code', 'proj-launch-vscode', `Open ${project.name} in VS Code`, () => void openProjectInEditor(project.path), ), ) return row } export function makeProjectCard( project: ProjectInfo, favs: ReadonlySet, hooks: ProjectsHooks, onToggleFav: (path: string) => void, onOpenDetail?: (path: string) => void, ): 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) }) const name = el('span', 'proj-name', 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) } 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)) } card.append(sessionsList) } // ── Launchers (Claude · Codex · VS Code) — always available ───────────────── card.append(makeLauncherRow(project, hooks)) return card } /* ── Detail view (branch / worktrees + active sessions) ─────────────────────── */ /** One worktree row: branch (or detached@head) + main/current/locked tags + path. */ function makeWorktreeRow(wt: WorktreeInfo): HTMLElement { const row = el('div', 'proj-wt-row') const label = wt.branch ?? (wt.head ? `detached @ ${wt.head}` : 'detached') row.append(el('span', 'proj-wt-branch', label)) if (wt.isMain) row.append(el('span', 'proj-wt-tag', 'main')) if (wt.isCurrent) row.append(el('span', 'proj-wt-tag proj-wt-tag-current', 'current')) if (wt.locked) row.append(el('span', 'proj-wt-tag', 'locked')) if (wt.prunable) row.append(el('span', 'proj-wt-tag', 'prunable')) row.append(el('span', 'proj-wt-path', wt.path)) return row } /** One detailed session row: status + title + metadata + Open / Kill actions. */ function makeDetailSessionRow( s: ProjectSessionRef, onEnter: (id: string) => void, onKill: (id: string) => void, ): HTMLElement { const row = el('div', 'proj-dsession') const dot = el('span', sessionDotClass(s.status)) const main = el('div', 'proj-dsession-main') main.append(el('span', 'proj-dsession-title', s.title ?? 'session')) const parts: string[] = [] if (s.status !== 'unknown') parts.push(statusText(s.status)) parts.push(`👁 ${s.clientCount}`) parts.push(`started ${relTime(s.createdAt)} ago`) main.append(el('span', 'proj-dsession-meta', parts.join(' · '))) const open = el('button', 'proj-dsession-open', 'Open') open.addEventListener('click', () => onEnter(s.id)) const kill = el('button', 'proj-dsession-kill', '✕') kill.title = 'Kill this session' kill.setAttribute('aria-label', 'Kill session') kill.addEventListener('click', () => onKill(s.id)) row.append(dot, main, open, kill) return row } interface DetailCallbacks { onBack: () => void onKill: (id: string) => void } /** Render the project detail view (exported for unit tests). */ export function renderProjectDetail( detail: ProjectDetail | null, hooks: ProjectsHooks, cb: DetailCallbacks, ): HTMLElement { const root = el('div', 'proj-detail-inner') const back = el('button', 'proj-back', '← All projects') back.addEventListener('click', () => 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) } root.append(head) root.append(el('div', 'proj-detail-path', detail.path)) // Branch / worktrees root.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch')) if (!detail.isGit) { root.append(el('div', 'proj-empty', 'Not a git repository.')) } else if (detail.worktrees.length === 0) { root.append( el('div', 'proj-empty', detail.branch ? `On branch ${detail.branch}` : 'No worktree info.'), ) } else { const list = el('div', 'proj-wt-list') for (const wt of detail.worktrees) list.append(makeWorktreeRow(wt)) root.append(list) } // 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) } // Launchers root.append(el('div', 'proj-section-title', 'Start')) root.append(makeLauncherRow(detail, hooks)) return root } /* ── 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 = getFavs() let timer: ReturnType | null = null let view: 'grid' | 'detail' = 'grid' let detailPath: string | null = null 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 renderGrid(): void { const filtered = filterProjects(allProjects, searchInput.value) const sorted = sortProjects(filtered, favs) grid.replaceChildren() if (sorted.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 p of sorted) { grid.append( makeProjectCard( p, favs, hooks, (path) => { favs = toggleFav(favs, path) saveFavs(favs) renderGrid() }, openDetail, ), ) } } function renderDetail(detail: ProjectDetail | null): void { detailEl.replaceChildren( renderProjectDetail(detail, hooks, { onBack: closeDetail, onKill: (id) => { void killSession(id).then(() => { if (view === 'detail') void refresh() }) }, }), ) } async function refresh(): Promise { if (view === 'detail' && detailPath !== null) { renderDetail(await fetchProjectDetail(detailPath)) return } allProjects = await fetchProjects() favs = getFavs() renderGrid() } 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 refresh() 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() }, } }