Home screen gains a Sessions↔Projects segmented control. The Projects view discovers the host's git repos and, on click, opens a tab named after the repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project). Backend (P2/P4): - src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions): depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops descending at a repo), .git/HEAD branch parse, rate-limited `git status` dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session merge (fresh each call), TTL discovery cache with in-flight dedup. - src/server.ts — read-only GET /projects (no Origin guard, []-fallback). Frontend (P5/P6): - public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot, session rows, "+ start claude here"), live search, localStorage favourites; pure helpers extracted for unit tests. - public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'), countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView). - public/style.css — Projects panel + .home-seg control styles. Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already added; this commit adds fail-fast rejection of relative PROJECT_ROOTS. Review hardening (4 confirmed HIGH + boundary validation): - carriage-return fix so claude auto-executes (A3); seg-control z-order; parallelised history merge; concurrent-cache-miss dedup. - normalizeProject guards the /projects response; getFavs filters non-strings. Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%. Verified: both typechecks clean, full suite green, build:web OK, coverage ≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
/**
|
||
* 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<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)
|
||
})
|
||
}
|
||
|
||
/** 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'] } : {}),
|
||
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 []
|
||
}
|
||
}
|
||
|
||
/* ── 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<string>,
|
||
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<string> = getFavs()
|
||
let timer: ReturnType<typeof setInterval> | 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<void> {
|
||
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()
|
||
},
|
||
}
|
||
}
|