/** * 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 } from '../src/types.js' import { el, relTime } 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 [] } } /* ── 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, ): 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) 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 } /* ── 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) let allProjects: ProjectInfo[] = [] let favs: Set = getFavs() let timer: ReturnType | null = null 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() }), ) } } async function refresh(): Promise { allProjects = await fetchProjects() favs = getFavs() renderGrid() } searchInput.addEventListener('input', () => renderGrid()) return { setVisible(v: boolean): void { root.style.display = v ? 'block' : 'none' if (v) { 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() }, } }