An audit of the project-detail panel against docs/mockups/project-detail-git.html turned up 59 differences. The ones that changed what you see: Controls that had class names but no rules, so they rendered as raw OS widgets: - .proj-wt-form had no layout at all — the branch input and Create Worktree button sat flush together, and the button jumped to its own line the moment a validation error appeared between them. - .proj-wt-error / .proj-wt-actions-error had no rule, so a validation failure rendered as 16px near-white body text, indistinguishable from content. - .proj-wt-remove (the worktree ✕) and .proj-wt-prune had no rule either. - .proj-fanout-mode never reset `appearance`, so WebKit kept its own light dropdown — the one control in the panel that stayed a native grey widget. - --danger was never defined, so every validation message fell back to an off-palette red. Cascade bugs — a later rule at equal specificity was silently winning: - .proj-wt-row-current's amber wash and border were completely dead. "You are here" rendered identically to every other row. - .proj-wt-branch inherited the 16px user-agent default in the UI sans, making a branch name the largest text on the page. Now 13.5px mono. Layout: - .proj-syncband dropped flex-wrap. Wrapping produced ragged half-empty rows between 721px and 1000px — at 900px the Fetch button dropped alone onto a second row. One cell now absorbs the shrink so the band stays a single row down to the 720px column break. - Commit rows are a 4-column grid, not flex, and the ↑ mark cell is rendered on every row instead of only unpushed ones. Previously no two lines agreed on where the sha started, so the unpushed group did not read as a group. - Pushed commit subjects step down to --text-dim, which is what makes the upstream boundary mean anything. Honesty of the sync band — the design's two hard rules: - Green "✓ in sync" was reachable when ahead/behind were UNKNOWN, because `?? 0` folded undefined into zero. Unknown counts now render "↑ —" / "↓ —" and are marked unverified. Green requires real zeros and a fresh fetch. - A stale ↓ painted the digit warning-red. Since a missing FETCH_HEAD is the default state, the largest glyph in the band was almost always red and the panel read as "this repo is broken". The doubt now sits on the `unverified` chip and the footnote, never on the number. Also: semantic tokens (--sunk/--warn/--ok/--dirty/--mono-font) replacing hex literals, one chip language across the panel, section headings as tracked caps with the count as a separate lighter element, and a ≤720px rule that drops the age column — placed after the base rule, since a media query adds no specificity. Verified in a real browser at 1280/1000/900/780/721/600/420px: the sync band holds one row down to 721px and stacks below it, commit columns align on every row, and nothing overflows or scrolls the page horizontally.
193 lines
8.1 KiB
TypeScript
193 lines
8.1 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')
|
|
// The mark cell is rendered on EVERY row, empty when the commit is pushed.
|
|
// Appending it only for unpushed rows left the grid's first track unoccupied on
|
|
// pushed rows, so the sha, age and subject columns shifted between lines and
|
|
// the unpushed group stopped reading as a block.
|
|
const mark = el('span', 'proj-commit-mark', c.unpushed === true ? '↑' : '')
|
|
if (c.unpushed === true) {
|
|
row.classList.add('proj-commit-unpushed')
|
|
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 = ''
|
|
},
|
|
}
|
|
}
|