// The history view (spec §5.6): a webview drawing the bookmark's changes // as a graph of dependencies, newest first, heads highlighted, with no // fake merge nodes. Clicking a change asks the extension to show it. import * as vscode from "vscode"; import { LogEntry } from "./oxy"; import { Repository } from "./repository"; import { timeAgo } from "./operations"; export class HistoryPanel { private static panels = new Map(); private readonly panel: vscode.WebviewPanel; private disposed = false; static show(repo: Repository, onOpen: (hash: string) => void): void { const existing = HistoryPanel.panels.get(repo.root); if (existing && !existing.disposed) { existing.panel.reveal(); void existing.render(); return; } HistoryPanel.panels.set(repo.root, new HistoryPanel(repo, onOpen)); } static refresh(root: string): void { const p = HistoryPanel.panels.get(root); if (p && !p.disposed) { void p.render(); } } private constructor( private readonly repo: Repository, onOpen: (hash: string) => void, ) { this.panel = vscode.window.createWebviewPanel("oxygen.history", `History: ${repo.root.split("/").pop()}`, vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true }); this.panel.iconPath = new vscode.ThemeIcon("git-commit"); this.panel.webview.onDidReceiveMessage((m: { type: string; hash?: string }) => { if (m.type === "open" && m.hash) { onOpen(m.hash); } else if (m.type === "refresh") { void this.render(); } }); this.panel.onDidDispose(() => { this.disposed = true; HistoryPanel.panels.delete(repo.root); }); void this.render(); } private async render(): Promise { let log; try { log = await this.repo.oxy.log(this.repo.root, 300); } catch (e) { this.panel.webview.html = `

${escape(String(e))}

`; return; } const changes = log.changes; const lanes = layout(changes); this.panel.webview.html = html(this.repo, log.bookmark, changes, lanes); } } /** Assign each change a lane so that dependency edges flow downward. */ function layout(changes: LogEntry[]): Map { const lanes = new Map(); const active: (string | undefined)[] = []; for (const c of changes) { let lane = active.indexOf(c.hash); if (lane < 0) { lane = active.indexOf(undefined); if (lane < 0) { lane = active.length; active.push(undefined); } } lanes.set(c.hash, lane); // The first dependency continues this lane; others take free lanes. const deps = c.deps.filter((d) => changes.some((x) => x.hash === d)); active[lane] = deps[0]; for (const d of deps.slice(1)) { if (!active.includes(d)) { const free = active.indexOf(undefined, lane + 1); if (free >= 0) { active[free] = d; } else { active.push(d); } } } // Lanes whose expected change has appeared elsewhere are free again. for (let i = 0; i < active.length; i++) { if (active[i] !== undefined && lanes.has(active[i]!)) { active[i] = undefined; } } } return lanes; } function html(repo: Repository, bookmark: string, changes: LogEntry[], lanes: Map): string { const rowH = 30; const laneW = 18; const maxLane = Math.max(0, ...lanes.values()); const graphW = (maxLane + 1) * laneW + 10; const index = new Map(changes.map((c, i) => [c.hash, i])); const edges: string[] = []; const nodes: string[] = []; changes.forEach((c, i) => { const x = lanes.get(c.hash)! * laneW + 10; const y = i * rowH + rowH / 2; for (const d of c.deps) { const j = index.get(d); if (j === undefined) { continue; } const x2 = lanes.get(d)! * laneW + 10; const y2 = j * rowH + rowH / 2; edges.push(``); } const cls = c.working_copy ? "wc" : c.head ? "head" : "node"; nodes.push(``); }); const rows = changes .map((c, i) => { const subject = c.message.split("\n")[0] || (c.working_copy ? "(working copy)" : "(no description)"); const sig = c.working_copy ? "" : c.signature?.status === "signed" || c.signature?.status === "sealed" ? (c.signature.revoked ? "⚠" : "✔") : c.signature?.status === "invalid" ? "✖" : "·"; return `
${c.change_id.slice(0, 8)} ${escape(subject)} ${escape(c.author)} · ${timeAgo(c.when_ms)} ${sig}
`; }) .join(""); return `

${escape(bookmark)} ${changes.length - 1} changes

${edges.join("")}${nodes.join("")}
${rows}
`; } function escape(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } export function repositoryLabel(repo: Repository): string { return repo.root.split("/").pop() ?? repo.root; }