/** * public/git-log.ts (W3 quick-wins d) — render-only recent-commit list. * * Fetches GET /projects/log for a repo and renders each commit as an inert row. * Zero parsing lives here (parsing is in src/http/git-log.ts). It NEVER throws * and degrades to a short inert message on any failure. * * Security: SEC-H5 — ALL text is set via textContent / el(). Zero innerHTML. A * commit subject is attacker-influenced (anyone who can push to a repo the host * can read), so it appears strictly as literal text. */ import type { CommitLogEntry, GitLogResult } from '../src/types.js' /* ── DOM helper ──────────────────────────────────────────────────────────────── */ /** Create an element with an optional CSS class and text content. */ function el( 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 } /* ── normalize (never trust the API shape) ───────────────────────────────────── */ /** Coerce one untrusted /projects/log element into a safe CommitLogEntry, or null. */ function normalizeCommit(raw: unknown): CommitLogEntry | null { if (raw === null || typeof raw !== 'object') return null const o = raw as Record if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null const entry: CommitLogEntry = { hash: o['hash'], at: o['at'], subject: o['subject'] } // w6/G4: only an explicit `true` marks a commit unpushed — anything else (absent, // truthy junk, an older server) must fall back to "pushed", because the failure // that matters is calling an unpushed commit pushed, not the reverse. return o['unpushed'] === true ? { ...entry, unpushed: true } : entry } /** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */ export function normalizeGitLog(raw: unknown): GitLogResult | null { if (raw === null || typeof raw !== 'object') return null const o = raw as Record if (!Array.isArray(o['commits'])) return null const commits = o['commits'] .map(normalizeCommit) .filter((c): c is CommitLogEntry => c !== null) const upstream = typeof o['upstream'] === 'string' && o['upstream'] !== '' ? o['upstream'] : undefined return { commits, truncated: o['truncated'] === true, ...(upstream !== undefined ? { upstream } : {}), } } /* ── fetch ───────────────────────────────────────────────────────────────────── */ /** Fetch the recent-commit log for a repo path. null on any error (best-effort). */ export async function fetchGitLog(repoPath: string): Promise { try { if (typeof fetch === 'undefined') return null const res = await fetch(`/projects/log?path=${encodeURIComponent(repoPath)}`) if (!res.ok) return null return normalizeGitLog(await res.json()) } catch { return null } } /* ── render ──────────────────────────────────────────────────────────────────── */ /** Coarse "Ns / Nm / Nh / Nd ago" formatter (local copy — avoids importing xterm). */ function relTime(ms: number): string { const s = Math.max(0, (Date.now() - ms) / 1000) if (s < 60) return `${Math.floor(s)}s` if (s < 3600) return `${Math.floor(s / 60)}m` if (s < 86400) return `${Math.floor(s / 3600)}h` return `${Math.floor(s / 86400)}d` } /** One commit row: short hash · relative time · subject (all inert text). * w6/G4: an unpushed commit gets a rail + ↑ so the eye reads the group, not the * individual rows. */ function renderCommitRow(c: CommitLogEntry): HTMLElement { const row = el('div', 'proj-commit-row') if (c.unpushed === true) { row.classList.add('proj-commit-unpushed') const mark = el('span', 'proj-commit-mark', '↑') mark.title = 'Not pushed yet' row.append(mark) } row.append(el('span', 'proj-commit-hash', c.hash)) row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`)) row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent return row } /** Render a GitLogResult into a container (clears first). Empty → an inert note. */ export function renderGitLog( container: HTMLElement, log: GitLogResult, labelEl?: HTMLElement, ): void { container.textContent = '' // Design: the count sits ON the section heading, not on a line of its own — // it qualifies "Recent commits", it is not a separate statement. const badgeHost = labelEl ?? container badgeHost.querySelector('.proj-commitlog-count')?.remove() if (log.commits.length === 0) { container.append(el('div', 'proj-empty', 'No commits yet.')) return } // Design: the section label carries the unpushed count, so the number is // readable before the eye has walked the rows looking for rails. const unpushedCount = log.commits.filter((c) => c.unpushed === true).length if (unpushedCount > 0 && log.upstream !== undefined) { const badge = el('span', 'proj-commitlog-count', `↑ ${unpushedCount} unpushed`) badge.title = `${unpushedCount} commit(s) not on ${log.upstream}` badgeHost.append(badge) } const list = el('div', 'proj-commitlog-list') // w6/G4: draw the upstream's position ONCE, right after the last unpushed row. // Only with a named upstream — with nothing to compare against there is no // boundary to claim, and an invented one would be a lie about what is pushed. const lastUnpushed = log.upstream === undefined ? -1 : log.commits.reduce((acc, c, i) => (c.unpushed === true ? i : acc), -1) log.commits.forEach((c, i) => { list.append(renderCommitRow(c)) if (i === lastUnpushed) { const boundary = el('div', 'proj-commit-boundary') boundary.append(el('span', 'proj-commit-boundary-label', log.upstream as string)) boundary.append(el('span', 'proj-commit-boundary-rule')) list.append(boundary) } }) container.append(list) if (log.truncated) { container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`)) } } /* ── mount ───────────────────────────────────────────────────────────────────── */ /** Handle returned by mountGitLog for cleanup. */ export interface GitLogHandle { destroy(): void } /** * Mount a recent-commit list into `container`: show a loading placeholder, fetch * the log, then swap in the rows. A fetch failure degrades to a short inert * message (never throws). destroy() removes the node and cancels the swap. */ export function mountGitLog( container: HTMLElement, repoPath: string, labelEl?: HTMLElement, ): GitLogHandle { let destroyed = false container.textContent = '' container.append(el('div', 'proj-commitlog-loading', 'Loading commits…')) void (async () => { const log = await fetchGitLog(repoPath) if (destroyed) return if (log === null) { container.textContent = '' container.append(el('div', 'proj-empty', 'Could not read recent commits.')) return } renderGitLog(container, log, labelEl) })() return { destroy() { destroyed = true container.textContent = '' }, } }