Two layout defects the screenshots caught that the DOM tests could not. 1. `.proj-wt-row` was declared twice — my two-line-card rule earlier in the file, the original flex rule later. Same specificity, so the later copy won and the Open button sat against the text instead of at the card's right edge. Folded the grid into the ORIGINAL rule and left a note where the duplicate was. One rule per selector; a second declaration of a layout property is a silent override waiting to happen. 2. The unpushed count rendered as its own line under "Recent commits". The design has it ON the heading, because it qualifies that heading rather than making a separate statement. renderGitLog now takes the heading element and writes the badge there, clearing any previous badge first so a re-render cannot stack duplicates. Both were invisible to the existing assertions: they checked that the elements exist and carry the right text, which was true in each case. Added a test for the no-duplicate-badge path; the right-edge alignment is a pure CSS outcome and is verified by screenshot, not by the suite.
189 lines
7.7 KiB
TypeScript
189 lines
7.7 KiB
TypeScript
/**
|
|
* 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<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
|
|
}
|
|
|
|
/* ── 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<string, unknown>
|
|
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<string, unknown>
|
|
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<GitLogResult | null> {
|
|
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 = ''
|
|
},
|
|
}
|
|
}
|