Files
web-terminal/public/git-log.ts
Yaojia Wang 950d2298a1 fix(projects): close the design gaps in the git panel
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.
2026-07-29 20:35:07 +02:00

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 = ''
},
}
}