Files
web-terminal/public/projects.ts
Yaojia Wang 7b4adf5072 feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring
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).
2026-06-30 11:04:49 +02:00

319 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. 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 } 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()
},
}
}