Files
web-terminal/public/diff.ts
Yaojia Wang b119c31019 feat(diff): diff a whole branch vs a base (?base=<rev>) — review before landing (W3)
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.

- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
  attacker-supplied base never reaches a shell or acts as a git option:
  (1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
  chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
  <base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
  (3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
  divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
  disables Working/Staged tabs in base mode. public/projects.ts derives bases from
  the worktree branches ∪ current branch.

Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
2026-07-12 20:43:32 +02:00

405 lines
14 KiB
TypeScript

/**
* 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'],
base: typeof o['base'] === 'string' ? o['base'] : undefined,
}
}
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 ───────────────────────────────────────────────────────────────── */
/** Options for {@link fetchDiff}: a `base` revision (branch/tag/sha) takes
* precedence over `staged` — the server ignores `staged` when `base` is set. */
export interface FetchDiffOpts {
staged?: boolean
base?: string
}
/**
* Fetch a diff from the server for the given repo path. When `opts.base` is set
* the URL carries `&base=<rev>` (working-tree/staged are omitted); otherwise it
* carries `&staged=<bool>`. Returns null on any error or invalid response.
*/
export async function fetchDiff(repoPath: string, opts: FetchDiffOpts = {}): Promise<DiffResult | null> {
try {
let url = `/projects/diff?path=${encodeURIComponent(repoPath)}`
if (opts.base !== undefined && opts.base !== '') {
url += `&base=${encodeURIComponent(opts.base)}`
} else {
url += `&staged=${opts.staged === true}`
}
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
/** Base revisions (branches) offered in the "compare base" picker. When
* empty/omitted no picker is rendered (backward-compatible). */
bases?: string[]
}
/**
* 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
let base: string | null = null // null → working-tree/staged mode; string → base-diff
// ── 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)
// Optional "compare against base" picker: Working tree + one option per base.
const bases = opts.bases ?? []
let baseSelect: HTMLSelectElement | null = null
if (bases.length > 0) {
baseSelect = document.createElement('select')
baseSelect.className = 'df-base-select'
baseSelect.setAttribute('aria-label', 'Compare against base')
const wtOpt = el('option', undefined, 'Working tree')
wtOpt.value = ''
baseSelect.append(wtOpt)
for (const b of bases) {
const opt = el('option', undefined, b) // textContent — inert (SEC-H4)
opt.value = b
baseSelect.append(opt)
}
baseSelect.addEventListener('change', () => {
const v = baseSelect?.value ?? ''
base = v === '' ? null : v
updateTabState()
void loadDiff()
})
toolbar.append(baseSelect)
}
toolbar.append(closeBtn)
root.append(toolbar)
// In base mode the Working/Staged tabs are inert (a base diff can't be staged).
function updateTabState(): void {
const inBase = base !== null
workingBtn.disabled = inBase
stagedBtn.disabled = inBase
workingBtn.classList.toggle('df-tab-disabled', inBase)
stagedBtn.classList.toggle('df-tab-disabled', inBase)
}
// 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, base !== null ? { base } : { 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 = ''
},
}
}