feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
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.
This commit is contained in:
347
public/diff.ts
Normal file
347
public/diff.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* public/diff.ts — N-diff-ui: diff viewer (render-only).
|
||||
*
|
||||
* This module is RENDER-ONLY: it receives pre-parsed DiffResult/DiffFile/DiffLine
|
||||
* structures from the server (GET /projects/diff) and renders them as DOM elements.
|
||||
* Zero diff parsing lives here (parsing is in src/http/diff.ts, review #2).
|
||||
*
|
||||
* Security: SEC-H4 — ALL diff content is set via textContent / el(). Zero innerHTML
|
||||
* anywhere in this file. <script>, & and ANSI escape sequences appear as plain text.
|
||||
*/
|
||||
|
||||
import type { DiffResult, DiffFile, DiffHunk, DiffLine } from '../src/types.js'
|
||||
|
||||
/* ── constants ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const EMPTY_MESSAGE = 'No changes'
|
||||
|
||||
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Create an element with an optional CSS class and text content. */
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
cls?: string,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag)
|
||||
if (cls) node.className = cls
|
||||
if (text !== undefined) node.textContent = text
|
||||
return node
|
||||
}
|
||||
|
||||
/* ── normalizeDiffResult ─────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Coerce an untrusted API response into a DiffResult, or return null.
|
||||
* Never throws. Filters out any file entries that are not valid objects.
|
||||
*/
|
||||
export function normalizeDiffResult(raw: unknown): DiffResult | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
|
||||
if (!Array.isArray(o['files'])) return null
|
||||
if (typeof o['staged'] !== 'boolean') return null
|
||||
if (typeof o['truncated'] !== 'boolean') return null
|
||||
|
||||
const files = (o['files'] as unknown[])
|
||||
.map(normalizeFile)
|
||||
.filter((f): f is DiffFile => f !== null)
|
||||
|
||||
return { files, staged: o['staged'], truncated: o['truncated'] }
|
||||
}
|
||||
|
||||
function normalizeFile(raw: unknown): DiffFile | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
|
||||
if (typeof o['oldPath'] !== 'string') return null
|
||||
if (typeof o['newPath'] !== 'string') return null
|
||||
if (typeof o['status'] !== 'string') return null
|
||||
if (typeof o['added'] !== 'number') return null
|
||||
if (typeof o['removed'] !== 'number') return null
|
||||
if (typeof o['binary'] !== 'boolean') return null
|
||||
if (!Array.isArray(o['hunks'])) return null
|
||||
|
||||
const hunks = (o['hunks'] as unknown[])
|
||||
.map(normalizeHunk)
|
||||
.filter((h): h is DiffHunk => h !== null)
|
||||
|
||||
return {
|
||||
oldPath: o['oldPath'],
|
||||
newPath: o['newPath'],
|
||||
status: o['status'] as DiffFile['status'],
|
||||
added: o['added'],
|
||||
removed: o['removed'],
|
||||
binary: o['binary'],
|
||||
hunks,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHunk(raw: unknown): DiffHunk | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
|
||||
if (typeof o['header'] !== 'string') return null
|
||||
if (!Array.isArray(o['lines'])) return null
|
||||
|
||||
const lines = (o['lines'] as unknown[])
|
||||
.map(normalizeLine)
|
||||
.filter((l): l is DiffLine => l !== null)
|
||||
|
||||
return { header: o['header'], lines }
|
||||
}
|
||||
|
||||
function normalizeLine(raw: unknown): DiffLine | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
|
||||
if (typeof o['kind'] !== 'string') return null
|
||||
if (typeof o['text'] !== 'string') return null
|
||||
|
||||
return { kind: o['kind'] as DiffLine['kind'], text: o['text'] }
|
||||
}
|
||||
|
||||
/* ── fetchDiff ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Fetch a diff from the server for the given repo path.
|
||||
* Returns null on any error or invalid response (best-effort).
|
||||
*/
|
||||
export async function fetchDiff(repoPath: string, staged: boolean): Promise<DiffResult | null> {
|
||||
try {
|
||||
const url = `/projects/diff?path=${encodeURIComponent(repoPath)}&staged=${staged}`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
const data: unknown = await res.json()
|
||||
return normalizeDiffResult(data)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ── renderDiffFile ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/** CSS class prefix for diff line kinds. */
|
||||
const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
|
||||
added: 'df-added',
|
||||
removed: 'df-removed',
|
||||
context: 'df-context',
|
||||
hunk: 'df-hunk',
|
||||
meta: 'df-meta',
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single DiffFile into an HTMLElement.
|
||||
*
|
||||
* Security: ALL text content is set via textContent — zero innerHTML.
|
||||
* <script>, ANSI sequences, & etc. are rendered as literal characters.
|
||||
*/
|
||||
export function renderDiffFile(file: DiffFile): HTMLElement {
|
||||
const section = el('div', 'df-file')
|
||||
|
||||
// ── file header ──────────────────────────────────────────────────────────
|
||||
const header = el('div', 'df-file-header')
|
||||
|
||||
const pathEl = el('span', 'df-path')
|
||||
if (file.status === 'renamed' && file.oldPath !== file.newPath) {
|
||||
pathEl.textContent = `${file.oldPath} → ${file.newPath}`
|
||||
} else {
|
||||
pathEl.textContent = file.newPath
|
||||
}
|
||||
|
||||
const statsEl = el('span', 'df-stats')
|
||||
const addedEl = el('span', 'df-stat-added', `+${file.added}`)
|
||||
const removedEl = el('span', 'df-stat-removed', `-${file.removed}`)
|
||||
statsEl.append(addedEl, removedEl)
|
||||
|
||||
const statusEl = el('span', `df-status df-status-${file.status}`, file.status)
|
||||
|
||||
header.append(pathEl, statsEl, statusEl)
|
||||
section.append(header)
|
||||
|
||||
// ── binary indicator ─────────────────────────────────────────────────────
|
||||
if (file.binary) {
|
||||
section.append(el('div', 'df-binary', 'Binary file'))
|
||||
return section
|
||||
}
|
||||
|
||||
// ── hunks ─────────────────────────────────────────────────────────────────
|
||||
for (const hunk of file.hunks) {
|
||||
section.append(renderHunk(hunk))
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
function renderHunk(hunk: DiffHunk): HTMLElement {
|
||||
const hunkEl = el('div', 'df-hunk-block')
|
||||
|
||||
// Hunk header (e.g. "@@ -1,3 +1,4 @@")
|
||||
hunkEl.append(el('div', 'df-hunk-header', hunk.header))
|
||||
|
||||
const linesEl = el('div', 'df-lines')
|
||||
for (const line of hunk.lines) {
|
||||
linesEl.append(renderLine(line))
|
||||
}
|
||||
hunkEl.append(linesEl)
|
||||
|
||||
return hunkEl
|
||||
}
|
||||
|
||||
function renderLine(line: DiffLine): HTMLElement {
|
||||
const cls = LINE_KIND_CLASS[line.kind] ?? 'df-context'
|
||||
return el('div', `df-line ${cls}`, line.text)
|
||||
}
|
||||
|
||||
/* ── renderDiff ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Render a full DiffResult: all files grouped, with empty state and truncated warning.
|
||||
*
|
||||
* Security: ALL content via textContent — zero innerHTML.
|
||||
*/
|
||||
export function renderDiff(result: DiffResult): HTMLElement {
|
||||
const container = el('div', 'df-result')
|
||||
|
||||
// Truncated warning
|
||||
if (result.truncated) {
|
||||
container.append(
|
||||
el('div', 'df-truncated-warning', 'Result truncated — diff is too large to display fully.'),
|
||||
)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (result.files.length === 0) {
|
||||
container.append(el('div', 'df-empty', EMPTY_MESSAGE))
|
||||
return container
|
||||
}
|
||||
|
||||
for (const file of result.files) {
|
||||
container.append(renderDiffFile(file))
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
/* ── mountDiffViewer ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Handle returned by mountDiffViewer for programmatic control. */
|
||||
export interface DiffViewerHandle {
|
||||
/** Switch to the working-tree (unstaged) diff. */
|
||||
showWorking(): void
|
||||
/** Switch to the staged diff. */
|
||||
showStaged(): void
|
||||
/** Programmatically trigger the onClose callback. */
|
||||
close(): void
|
||||
/** Remove all DOM content from the container (cleanup). */
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
export interface MountDiffViewerOpts {
|
||||
/** Called when the viewer's close button or close() is invoked. */
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a diff viewer into `container`.
|
||||
*
|
||||
* Renders a toolbar (Working / Staged toggle + Close), then fetches and renders
|
||||
* the diff below it. The toggle re-fetches when switched.
|
||||
*
|
||||
* Security: SEC-H4 — all content via textContent/el(). Zero innerHTML.
|
||||
*/
|
||||
export function mountDiffViewer(
|
||||
container: HTMLElement,
|
||||
repoPath: string,
|
||||
opts: MountDiffViewerOpts,
|
||||
): DiffViewerHandle {
|
||||
let destroyed = false
|
||||
let staged = false
|
||||
|
||||
// ── skeleton ──────────────────────────────────────────────────────────────
|
||||
const root = el('div', 'df-viewer')
|
||||
|
||||
// Toolbar
|
||||
const toolbar = el('div', 'df-toolbar')
|
||||
|
||||
const workingBtn = el('button', 'df-tab df-tab-active', 'Working')
|
||||
const stagedBtn = el('button', 'df-tab', 'Staged')
|
||||
const closeBtn = el('button', 'df-close', '✕ Close')
|
||||
|
||||
toolbar.append(workingBtn, stagedBtn, closeBtn)
|
||||
root.append(toolbar)
|
||||
|
||||
// Content area
|
||||
const content = el('div', 'df-content')
|
||||
root.append(content)
|
||||
|
||||
container.append(root)
|
||||
|
||||
// ── event handlers ────────────────────────────────────────────────────────
|
||||
workingBtn.addEventListener('click', () => {
|
||||
if (!staged) return
|
||||
staged = false
|
||||
workingBtn.className = 'df-tab df-tab-active'
|
||||
stagedBtn.className = 'df-tab'
|
||||
void loadDiff()
|
||||
})
|
||||
|
||||
stagedBtn.addEventListener('click', () => {
|
||||
if (staged) return
|
||||
staged = true
|
||||
stagedBtn.className = 'df-tab df-tab-active'
|
||||
workingBtn.className = 'df-tab'
|
||||
void loadDiff()
|
||||
})
|
||||
|
||||
closeBtn.addEventListener('click', () => {
|
||||
opts.onClose?.()
|
||||
})
|
||||
|
||||
// ── fetch + render ────────────────────────────────────────────────────────
|
||||
async function loadDiff(): Promise<void> {
|
||||
if (destroyed) return
|
||||
|
||||
content.textContent = 'Loading…'
|
||||
|
||||
const result = await fetchDiff(repoPath, staged)
|
||||
|
||||
if (destroyed) return
|
||||
|
||||
content.textContent = ''
|
||||
if (result === null) {
|
||||
content.append(el('div', 'df-error', 'Failed to load diff.'))
|
||||
} else {
|
||||
content.append(renderDiff(result))
|
||||
}
|
||||
}
|
||||
|
||||
void loadDiff()
|
||||
|
||||
// ── handle ────────────────────────────────────────────────────────────────
|
||||
return {
|
||||
showWorking() {
|
||||
if (staged) {
|
||||
staged = false
|
||||
workingBtn.className = 'df-tab df-tab-active'
|
||||
stagedBtn.className = 'df-tab'
|
||||
void loadDiff()
|
||||
}
|
||||
},
|
||||
showStaged() {
|
||||
if (!staged) {
|
||||
staged = true
|
||||
stagedBtn.className = 'df-tab df-tab-active'
|
||||
workingBtn.className = 'df-tab'
|
||||
void loadDiff()
|
||||
}
|
||||
},
|
||||
close() {
|
||||
opts.onClose?.()
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true
|
||||
container.textContent = ''
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user