Files
web-terminal/public/projects.ts
Yaojia Wang a390130205 feat(v0.6): project card launchers — Claude/Codex/VS Code logos + active highlight
Replace each card's "+ start claude here" with a row of three brand-logo
launcher buttons (logo + caption, touch-friendly, tooltips):

- Claude (coral burst)  → new terminal tab running `claude`
- Codex  (OpenAI mark)  → new terminal tab running `codex`
- VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST

VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default
`code`) on the host via execFile (no shell); path validated as an existing
absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added.

Active-session highlight (user request): when a project has a running Claude
session (Claude-hook status != unknown — plain shells/codex stay unknown), the
Claude logo gets a coral ring + tint, with a count badge when more than one.

New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts.
onOpenProject gains an optional cmd; makeProjectCard exported for tests.

Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher
row (3 buttons, claude/codex commands), and the active-Claude highlight + badge.
Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3
logos render with brand colours, and POST /open-in-editor actually launched
VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
2026-06-30 12:46:12 +02:00

393 lines
14 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'
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<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
}
/** Ask the host to open this project in the editor (POST /open-in-editor → `code <path>`). */
async function openProjectInEditor(repoPath: string): Promise<void> {
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<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) — 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<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()
},
}
}