The shipped panel was functionally right — every number matched git — but it was not the approved design. I implemented from the plan's semantics (which state is green, when to flag stale) and let the mock's layout drift, and nothing caught it: the tests asserted behaviour, and no assertion covered structure. Three deviations, now closed against docs/mockups/project-detail-git.html. 1. The sync band was compressed into one row of chips. Restored to the four labelled cells: Upstream / To push / To pull / Fetch, with display-size numerals and a footnote under each count. The footnotes are load-bearing, not decoration — "Last fetched 19d ago — this number cannot be trusted" is what tells a reader WHY the zero is suspect. Compressed to "fetched 19d ago", the causal link was left for the reader to infer, and not having to infer it is the entire point of the panel. The unverified state is a chip on the count again, so the doubt attaches to the digit. 2. The working-tree count sat inside the band. Moved to the title line beside the branch, where the design puts it: the band is about the remote, and the dirty count is not. Reads "● 3 uncommitted" instead of a bare dot; the dot remains as the fallback when a server sends no count. 3. Worktree rows were the old single line with the path right-aligned against the chips. Rebuilt as the two-line card the design calls for — identity and state on top, path underneath — plus the Open button, which was specified and simply missing, so a worktree row could be read but not entered. Open reuses the detail view pointed at the worktree path: a linked worktree is a project directory, so this needs no new concept and no new route. Also added the unpushed count beside the commit-list heading, so the number is readable before the eye walks the rows hunting for rails. Tests now assert structure, not just behaviour: cell captions exist, the stale footnote says why, counts render at display size, the path is outside the chip row, and Open fires with its worktree. That is the gap that let the drift through, so it is the gap that is now covered. Verified: tsc and build clean; npm test green (unit 78 files / 2159, e2e 27).
177 lines
7.4 KiB
TypeScript
177 lines
7.4 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): void {
|
|
container.textContent = ''
|
|
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('div', 'proj-commitlog-count', `↑ ${unpushedCount} unpushed`)
|
|
badge.title = `${unpushedCount} commit(s) not on ${log.upstream}`
|
|
container.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): 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)
|
|
})()
|
|
|
|
return {
|
|
destroy() {
|
|
destroyed = true
|
|
container.textContent = ''
|
|
},
|
|
}
|
|
}
|