/** * 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' /* ── 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 claude session in the given repo. */ onOpenProject: (repoPath: string, repoName: 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 } 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) ─────────────────────────────────────────────────── const runningSessions = project.sessions.filter((s) => !s.exited) if (runningSessions.length === 0) { // 0 running sessions: single primary "start claude here" action const newBtn = el('button', 'proj-new proj-new-primary', '+ start claude here') newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name)) card.append(newBtn) } else { // 1+ running sessions: list session rows + always-present "+ New" button const sessionsList = el('div', 'proj-sessions-list') for (const sess of runningSessions) { sessionsList.append(makeSessionRow(sess, hooks.onEnterSession)) } card.append(sessionsList) const newBtn = el('button', 'proj-new', '+ New') newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name)) card.append(newBtn) } 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() }, } }