Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
808 lines
28 KiB
TypeScript
808 lines
28 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,
|
|
ProjectDetail,
|
|
WorktreeInfo,
|
|
CreateWorktreeResult,
|
|
} from '../src/types.js'
|
|
import { el, relTime, statusText } from './preview-grid.js'
|
|
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
|
|
import { mountDiffViewer, type DiffViewerHandle } from './diff.js'
|
|
import { mountTimeline, type TimelineHandle } from './timeline.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 []
|
|
}
|
|
}
|
|
|
|
/** Fetch one project's detail (branch/worktrees + sessions). null on any error. */
|
|
async function fetchProjectDetail(projectPath: string): Promise<ProjectDetail | null> {
|
|
try {
|
|
const res = await fetch(`/projects/detail?path=${encodeURIComponent(projectPath)}`)
|
|
if (!res.ok) return null
|
|
return (await res.json()) as ProjectDetail
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Kill a running session (manage-page DELETE; same-origin → Origin guard passes). */
|
|
async function killSession(id: string): Promise<void> {
|
|
try {
|
|
await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
} catch {
|
|
// best-effort — the refresh that follows will reflect the real state
|
|
}
|
|
}
|
|
|
|
/* ── 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,
|
|
onKill?: (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)
|
|
|
|
if (onKill) {
|
|
const kill = el('button', 'proj-session-kill', '✕')
|
|
kill.title = 'Kill this session'
|
|
kill.setAttribute('aria-label', 'Kill session')
|
|
// Don't let the kill button trigger the row's enter handler.
|
|
kill.addEventListener('pointerdown', (e) => e.stopPropagation())
|
|
kill.addEventListener('click', (e) => {
|
|
e.stopPropagation()
|
|
onKill(sess.id)
|
|
})
|
|
row.append(kill)
|
|
}
|
|
|
|
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,
|
|
onOpenDetail?: (path: string) => void,
|
|
onKillSession?: (id: 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)
|
|
if (onOpenDetail) {
|
|
name.classList.add('proj-name-link')
|
|
name.setAttribute('role', 'button')
|
|
name.tabIndex = 0
|
|
name.title = `View ${project.name} details`
|
|
name.addEventListener('click', () => onOpenDetail(project.path))
|
|
name.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
onOpenDetail(project.path)
|
|
}
|
|
})
|
|
}
|
|
|
|
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, onKillSession))
|
|
}
|
|
card.append(sessionsList)
|
|
}
|
|
|
|
// ── Launchers (Claude · Codex · VS Code) — always available ─────────────────
|
|
card.append(makeLauncherRow(project, hooks))
|
|
|
|
return card
|
|
}
|
|
|
|
/* ── Detail view (branch / worktrees + active sessions) ─────────────────────── */
|
|
|
|
/** One worktree row: branch (or detached@head) + main/current/locked tags + path. */
|
|
function makeWorktreeRow(wt: WorktreeInfo): HTMLElement {
|
|
const row = el('div', 'proj-wt-row')
|
|
const label = wt.branch ?? (wt.head ? `detached @ ${wt.head}` : 'detached')
|
|
row.append(el('span', 'proj-wt-branch', label))
|
|
if (wt.isMain) row.append(el('span', 'proj-wt-tag', 'main'))
|
|
if (wt.isCurrent) row.append(el('span', 'proj-wt-tag proj-wt-tag-current', 'current'))
|
|
if (wt.locked) row.append(el('span', 'proj-wt-tag', 'locked'))
|
|
if (wt.prunable) row.append(el('span', 'proj-wt-tag', 'prunable'))
|
|
row.append(el('span', 'proj-wt-path', wt.path))
|
|
return row
|
|
}
|
|
|
|
/** One detailed session row: status + title + metadata + Open / Kill actions. */
|
|
function makeDetailSessionRow(
|
|
s: ProjectSessionRef,
|
|
onEnter: (id: string) => void,
|
|
onKill: (id: string) => void,
|
|
): HTMLElement {
|
|
const row = el('div', 'proj-dsession')
|
|
const dot = el('span', sessionDotClass(s.status))
|
|
const main = el('div', 'proj-dsession-main')
|
|
main.append(el('span', 'proj-dsession-title', s.title ?? 'session'))
|
|
const parts: string[] = []
|
|
if (s.status !== 'unknown') parts.push(statusText(s.status))
|
|
parts.push(`👁 ${s.clientCount}`)
|
|
parts.push(`started ${relTime(s.createdAt)} ago`)
|
|
main.append(el('span', 'proj-dsession-meta', parts.join(' · ')))
|
|
const open = el('button', 'proj-dsession-open', 'Open')
|
|
open.addEventListener('click', () => onEnter(s.id))
|
|
const kill = el('button', 'proj-dsession-kill', '✕')
|
|
kill.title = 'Kill this session'
|
|
kill.setAttribute('aria-label', 'Kill session')
|
|
kill.addEventListener('click', () => onKill(s.id))
|
|
row.append(dot, main, open, kill)
|
|
return row
|
|
}
|
|
|
|
/** Returns null if `branch` is a valid git branch name, or an error message (B3). Never throws. */
|
|
export function validateBranchNameClient(branch: string): string | null {
|
|
if (!branch) return 'Branch name cannot be empty'
|
|
if (branch.length > 250) return 'Branch name is too long (max 250 characters)'
|
|
if (/[\x00-\x20\x7f]/.test(branch)) return 'Branch name cannot contain spaces or control characters'
|
|
if (branch.startsWith('-')) return 'Branch name cannot start with a hyphen'
|
|
if (branch.includes('..')) return 'Branch name cannot contain ".."'
|
|
if (branch.endsWith('.lock')) return 'Branch name cannot end with ".lock"'
|
|
if (/[~^:?*\[\\]/.test(branch)) return 'Branch name contains invalid characters'
|
|
if (branch.includes('@{')) return 'Branch name cannot contain "@{"'
|
|
if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) {
|
|
return 'Branch name has invalid slash usage'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function isWorktreeResult(v: unknown): v is CreateWorktreeResult {
|
|
return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
|
|
}
|
|
|
|
/** Render a "New Worktree" form (B3). Errors via textContent only (SEC-L3/H6). */
|
|
export function renderNewWorktreeForm(detail: ProjectDetail, hooks: ProjectsHooks): HTMLElement {
|
|
const form = el('div', 'proj-wt-form')
|
|
|
|
const input = document.createElement('input')
|
|
input.type = 'text'
|
|
input.className = 'proj-wt-branch-input'
|
|
input.placeholder = 'New branch name'
|
|
input.setAttribute('aria-label', 'New branch name')
|
|
input.maxLength = 250
|
|
|
|
const errorEl = el('div', 'proj-wt-error')
|
|
errorEl.style.display = 'none'
|
|
|
|
const submitBtn = el('button', 'proj-wt-submit', 'Create Worktree')
|
|
form.append(input, errorEl, submitBtn)
|
|
|
|
function showError(msg: string): void {
|
|
errorEl.textContent = msg // SEC-L3/H6: textContent, never innerHTML
|
|
errorEl.style.display = ''
|
|
submitBtn.disabled = false
|
|
}
|
|
function hideError(): void { errorEl.textContent = ''; errorEl.style.display = 'none' }
|
|
|
|
let busy = false
|
|
async function doCreate(): Promise<void> {
|
|
if (busy) return
|
|
const branch = input.value.trim()
|
|
const err = validateBranchNameClient(branch)
|
|
if (err !== null) { showError(err); return }
|
|
hideError()
|
|
busy = true
|
|
submitBtn.disabled = true
|
|
try {
|
|
const res = await fetch('/projects/worktree', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ repoPath: detail.path, branch }),
|
|
})
|
|
let data: unknown
|
|
try { data = await res.json() } catch { data = null }
|
|
if (!res.ok || !isWorktreeResult(data) || !data.ok) {
|
|
showError(isWorktreeResult(data) && typeof data.error === 'string'
|
|
? data.error : 'Failed to create worktree')
|
|
return
|
|
}
|
|
hooks.onOpenProject(data.path ?? detail.path, data.branch ?? branch, 'claude\r')
|
|
} catch {
|
|
showError('Failed to create worktree')
|
|
} finally {
|
|
busy = false
|
|
submitBtn.disabled = false
|
|
}
|
|
}
|
|
|
|
submitBtn.addEventListener('click', () => { void doCreate() })
|
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') void doCreate() })
|
|
input.addEventListener('input', () => { if (errorEl.style.display !== 'none') hideError() })
|
|
return form
|
|
}
|
|
|
|
|
|
/** Build the "View Diff" toggle + inline panel (B1). `diffRef.h` tracks the open handle. */
|
|
function buildDiffSection(
|
|
repoPath: string,
|
|
diffRef: { h: DiffViewerHandle | null },
|
|
): HTMLElement {
|
|
const section = el('div', 'proj-diff-section')
|
|
const toggle = el('button', 'proj-diff-toggle', 'View Diff')
|
|
const panel = el('div', 'proj-diff-panel')
|
|
panel.style.display = 'none'
|
|
|
|
toggle.addEventListener('click', () => {
|
|
if (panel.style.display === 'none') {
|
|
panel.style.display = ''
|
|
toggle.textContent = 'Hide Diff'
|
|
diffRef.h = mountDiffViewer(panel, repoPath, {
|
|
onClose: () => {
|
|
panel.style.display = 'none'
|
|
toggle.textContent = 'View Diff'
|
|
diffRef.h?.destroy()
|
|
diffRef.h = null
|
|
},
|
|
})
|
|
} else {
|
|
diffRef.h?.destroy()
|
|
diffRef.h = null
|
|
panel.style.display = 'none'
|
|
toggle.textContent = 'View Diff'
|
|
}
|
|
})
|
|
|
|
section.append(toggle, panel)
|
|
return section
|
|
}
|
|
|
|
/** Build the "Activity" section: one timeline panel per running session (A4). */
|
|
function buildActivitySection(
|
|
running: ProjectSessionRef[],
|
|
localTimelines: TimelineHandle[],
|
|
collector: TimelineHandle[] | undefined,
|
|
): HTMLElement | null {
|
|
if (running.length === 0) return null
|
|
const section = el('div', 'proj-activity-section')
|
|
for (const sess of running) {
|
|
const block = el('div', 'proj-activity-session')
|
|
block.append(el('div', 'proj-activity-label', sess.title ?? 'session'))
|
|
const tlContainer = el('div', 'proj-tl-container')
|
|
block.append(tlContainer)
|
|
const handle = mountTimeline(tlContainer, sess.id)
|
|
localTimelines.push(handle)
|
|
if (collector !== undefined) collector.push(handle)
|
|
section.append(block)
|
|
}
|
|
return section
|
|
}
|
|
|
|
interface DetailCallbacks {
|
|
onBack: () => void
|
|
onKill: (id: string) => void
|
|
}
|
|
|
|
/** Render the project detail view (exported for unit tests). `timelineCollector` receives A4 handles for re-render disposal. */
|
|
export function renderProjectDetail(
|
|
detail: ProjectDetail | null,
|
|
hooks: ProjectsHooks,
|
|
cb: DetailCallbacks,
|
|
timelineCollector?: TimelineHandle[],
|
|
): HTMLElement {
|
|
const root = el('div', 'proj-detail-inner')
|
|
const localTimelines: TimelineHandle[] = []
|
|
const diffRef: { h: DiffViewerHandle | null } = { h: null }
|
|
|
|
const back = el('button', 'proj-back', '← All projects')
|
|
back.addEventListener('click', () => {
|
|
diffRef.h?.destroy()
|
|
diffRef.h = null
|
|
for (const handle of localTimelines) handle.dispose()
|
|
cb.onBack()
|
|
})
|
|
root.append(back)
|
|
|
|
if (detail === null) {
|
|
root.append(el('div', 'proj-empty', 'Project not found.'))
|
|
return root
|
|
}
|
|
|
|
// Header: name + branch + dirty + path
|
|
const head = el('div', 'proj-detail-head')
|
|
head.append(el('h2', 'proj-detail-name', detail.name))
|
|
if (detail.branch) head.append(el('span', 'proj-branch', detail.branch))
|
|
if (detail.dirty === true) {
|
|
const d = el('span', 'proj-dirty', '●')
|
|
d.title = 'Uncommitted changes'
|
|
head.append(d)
|
|
}
|
|
root.append(head)
|
|
root.append(el('div', 'proj-detail-path', detail.path))
|
|
|
|
// B1: View Diff (git repos only) — SEC-H4 enforced inside mountDiffViewer
|
|
if (detail.isGit) root.append(buildDiffSection(detail.path, diffRef))
|
|
|
|
// Branch / worktrees
|
|
root.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch'))
|
|
if (!detail.isGit) {
|
|
root.append(el('div', 'proj-empty', 'Not a git repository.'))
|
|
} else if (detail.worktrees.length === 0) {
|
|
root.append(
|
|
el('div', 'proj-empty', detail.branch ? `On branch ${detail.branch}` : 'No worktree info.'),
|
|
)
|
|
} else {
|
|
const list = el('div', 'proj-wt-list')
|
|
for (const wt of detail.worktrees) list.append(makeWorktreeRow(wt))
|
|
root.append(list)
|
|
}
|
|
|
|
// B3: New Worktree form (git repos only)
|
|
if (detail.isGit) {
|
|
root.append(el('div', 'proj-section-title', 'New Worktree'))
|
|
root.append(renderNewWorktreeForm(detail, hooks))
|
|
}
|
|
|
|
// Active sessions
|
|
const running = detail.sessions.filter((s) => !s.exited)
|
|
root.append(el('div', 'proj-section-title', `Active sessions (${running.length})`))
|
|
if (running.length === 0) {
|
|
root.append(el('div', 'proj-empty', 'No running sessions — start one below.'))
|
|
} else {
|
|
const list = el('div', 'proj-detail-sessions')
|
|
for (const s of running) list.append(makeDetailSessionRow(s, hooks.onEnterSession, cb.onKill))
|
|
root.append(list)
|
|
}
|
|
|
|
// A4: Activity section — one timeline per running session, disposed on back/re-render
|
|
const activityEl = buildActivitySection(running, localTimelines, timelineCollector)
|
|
if (activityEl !== null) {
|
|
root.append(el('div', 'proj-section-title', 'Activity'))
|
|
root.append(activityEl)
|
|
}
|
|
|
|
// CLAUDE.md
|
|
const cmHead = el('div', 'proj-section-row')
|
|
cmHead.append(el('div', 'proj-section-title', 'CLAUDE.md'))
|
|
const cmBtn = el('button', 'proj-claudemd-btn', detail.hasClaudeMd ? '↻ Update' : '✨ Generate')
|
|
cmBtn.title = detail.hasClaudeMd
|
|
? 'Open a Claude session running /init to refresh CLAUDE.md'
|
|
: 'Open a Claude session running /init to create CLAUDE.md'
|
|
cmBtn.addEventListener('click', () => hooks.onOpenProject(detail.path, detail.name, 'claude "/init"\r'))
|
|
cmHead.append(cmBtn)
|
|
root.append(cmHead)
|
|
|
|
if (detail.hasClaudeMd && detail.claudeMd !== undefined) {
|
|
const pre = el('pre', 'proj-claudemd')
|
|
pre.textContent = detail.claudeMd
|
|
root.append(pre)
|
|
} else {
|
|
root.append(
|
|
el('div', 'proj-empty', 'No CLAUDE.md yet — generate one to give Claude project-specific instructions.'),
|
|
)
|
|
}
|
|
|
|
// Launchers
|
|
root.append(el('div', 'proj-section-title', 'Start'))
|
|
root.append(makeLauncherRow(detail, hooks))
|
|
|
|
return root
|
|
}
|
|
|
|
/* ── 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)
|
|
|
|
const detailEl = el('div', 'proj-detail')
|
|
detailEl.style.display = 'none'
|
|
root.append(detailEl)
|
|
|
|
let allProjects: ProjectInfo[] = []
|
|
let favs: Set<string> = getFavs()
|
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
let view: 'grid' | 'detail' = 'grid'
|
|
let detailPath: string | null = null
|
|
// A4: tracks timeline handles from the current detail render for re-render disposal
|
|
let detailTimelineHandles: TimelineHandle[] = []
|
|
|
|
function applyView(): void {
|
|
const isGrid = view === 'grid'
|
|
searchWrap.style.display = isGrid ? '' : 'none'
|
|
grid.style.display = isGrid ? '' : 'none'
|
|
detailEl.style.display = isGrid ? 'none' : 'block'
|
|
}
|
|
|
|
function openDetail(p: string): void {
|
|
view = 'detail'
|
|
detailPath = p
|
|
applyView()
|
|
void refresh()
|
|
}
|
|
|
|
function closeDetail(): void {
|
|
view = 'grid'
|
|
detailPath = null
|
|
applyView()
|
|
void refresh()
|
|
}
|
|
|
|
function killAndRefresh(id: string): void {
|
|
void killSession(id).then(() => void refresh())
|
|
}
|
|
|
|
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()
|
|
},
|
|
openDetail,
|
|
killAndRefresh,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
function renderDetail(detail: ProjectDetail | null): void {
|
|
// Dispose timelines from the previous render before replacing DOM (A4 re-render)
|
|
for (const h of detailTimelineHandles) h.dispose()
|
|
detailTimelineHandles = []
|
|
detailEl.replaceChildren(
|
|
renderProjectDetail(
|
|
detail,
|
|
hooks,
|
|
{
|
|
onBack: closeDetail,
|
|
onKill: (id) => {
|
|
void killSession(id).then(() => {
|
|
if (view === 'detail') void refresh()
|
|
})
|
|
},
|
|
},
|
|
detailTimelineHandles,
|
|
),
|
|
)
|
|
}
|
|
|
|
async function refresh(): Promise<void> {
|
|
if (view === 'detail' && detailPath !== null) {
|
|
renderDetail(await fetchProjectDetail(detailPath))
|
|
return
|
|
}
|
|
allProjects = await fetchProjects()
|
|
favs = getFavs()
|
|
renderGrid()
|
|
}
|
|
|
|
searchInput.addEventListener('input', () => renderGrid())
|
|
|
|
return {
|
|
setVisible(v: boolean): void {
|
|
root.style.display = v ? 'block' : 'none'
|
|
if (v) {
|
|
// Re-entering the chooser always lands on the grid (never a stale detail).
|
|
view = 'grid'
|
|
detailPath = null
|
|
applyView()
|
|
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()
|
|
},
|
|
}
|
|
}
|