// The Bisect and Trust views (spec §5.11, §5.12): small trees fed by // `oxy bisect status --json`, `oxy policy show --json`, `oxy verify --json` // and, when asked, `oxy tlog verify --json`. import * as vscode from "vscode"; import { BisectStatus, PolicyInfo, VerifyInfo } from "./oxy"; import { Repository } from "./repository"; import { icon as tinted } from "./style"; import { TlogCheck, tlogLines } from "./tlog"; import { timeAgo } from "./operations"; class Line extends vscode.TreeItem { constructor(label: string, opts: { description?: string; icon?: string; color?: string; command?: vscode.Command; tooltip?: string; context?: string } = {}) { super(label, vscode.TreeItemCollapsibleState.None); this.description = opts.description; this.tooltip = opts.tooltip; this.command = opts.command; this.contextValue = opts.context; if (opts.icon) { this.iconPath = tinted(opts.icon, opts.color ?? ICON_COLOR[opts.icon]); } } } /** The colour each icon of these views wears unless a line says otherwise. */ const ICON_COLOR: Record = { pass: "oxygen.good", verified: "oxygen.good", error: "oxygen.bad", bug: "oxygen.bad", warning: "oxygen.warning", unlock: "oxygen.warning", question: "oxygen.warning", search: "oxygen.accent", beaker: "oxygen.merge", "run-all": "oxygen.push", "debug-step-over": "descriptionForeground", discard: "oxygen.undo", shield: "oxygen.merge", key: "oxygen.key", law: "oxygen.key", "list-tree": "oxygen.accent", "circle-slash": "oxygen.bad", "circle-outline": "descriptionForeground", }; abstract class RepoTree implements vscode.TreeDataProvider { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.emitter.event; constructor(protected readonly repositories: () => Repository[]) {} refresh(): void { this.emitter.fire(); } getTreeItem(element: vscode.TreeItem): vscode.TreeItem { return element; } async getChildren(element?: vscode.TreeItem): Promise { const repos = this.repositories(); if (!element) { if (repos.length === 1) { return this.items(repos[0]); } return repos.map((r) => { const item = new vscode.TreeItem(r.root.split("/").pop() ?? r.root, vscode.TreeItemCollapsibleState.Expanded); item.iconPath = tinted("repo", "oxygen.accent"); (item as vscode.TreeItem & { repository?: Repository }).repository = r; return item; }); } const repo = (element as vscode.TreeItem & { repository?: Repository }).repository; return repo ? this.items(repo) : []; } protected abstract items(repo: Repository): Promise; } export class BisectProvider extends RepoTree { protected async items(repo: Repository): Promise { let status: BisectStatus | null; try { status = await repo.oxy.bisectStatus(repo.root); } catch (e) { return [new Line(String(e), { icon: "warning" })]; } const cmd = (title: string, command: string, args: unknown[] = []): vscode.Command => ({ title, command, arguments: [repo, ...args] }); if (!status) { return [new Line("No bisect in progress", { icon: "search", description: "🔍 start one to find a regression", command: cmd("Start", "oxygen.bisectStart") })]; } switch (status.state) { case "need-marks": return [ new Line(`Marked ${status.good} good, ${status.bad} bad`, { icon: "search" }), new Line(status.good === 0 ? "Mark a known-good change or bookmark…" : "Mark a known-bad change or bookmark…", { icon: status.good === 0 ? "pass" : "error", command: cmd("Mark", status.good === 0 ? "oxygen.bisectGood" : "oxygen.bisectBad") }), new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }), ]; case "testing": return [ new Line(`Testing: ${status.remaining} candidates left`, { icon: "beaker", description: `about ${status.steps_left} more steps` }), new Line("This state is good", { icon: "pass", command: cmd("Good", "oxygen.bisectGood", [[]]) }), new Line("This state is bad", { icon: "error", command: cmd("Bad", "oxygen.bisectBad", [[]]) }), new Line("Skip: cannot test this state", { icon: "debug-step-over", command: cmd("Skip", "oxygen.bisectSkip") }), new Line("Run a command for every step…", { icon: "run-all", command: cmd("Run", "oxygen.bisectRun") }), new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }), ]; case "found": return [ new Line(`Found: ${status.culprit.slice(0, 12)}`, { icon: "bug", description: "🎯 the first change that makes the state bad", command: { title: "Show", command: "oxygen.showChange", arguments: [repo.root, status.culprit] } }), new Line("Reset: back to where you were", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }), ]; case "inconclusive": return [ new Line(`Inconclusive: ${status.candidates.length} candidates could not be told apart`, { icon: "question" }), ...status.candidates.map((c) => new Line(c.slice(0, 12), { icon: "circle-outline", command: { title: "Show", command: "oxygen.showChange", arguments: [repo.root, c] } })), new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }), ]; } } } export class TrustProvider extends RepoTree { /** The last transparency log check per repository root; the log is checked on request, not on every refresh (it asks the hub). */ private readonly tlogChecks = new Map(); setTlog(root: string, check: TlogCheck): void { this.tlogChecks.set(root, check); this.refresh(); } protected async items(repo: Repository): Promise { let policy: PolicyInfo | null; let verify: VerifyInfo[]; try { [policy, verify] = await Promise.all([repo.oxy.policyShow(repo.root), repo.oxy.verify(repo.root)]); } catch (e) { return [new Line(String(e), { icon: "warning" })]; } const items: vscode.TreeItem[] = []; if (!policy) { items.push(new Line("No trust policy: this repository is unverified", { icon: "unlock", description: "🔓 oxy policy init creates one", tooltip: "Without a policy every change and reference update is accepted." })); } else { const expires = new Date(policy.expires_ms).toLocaleDateString(); items.push(new Line(`Policy serial ${policy.serial}`, { icon: policy.expired ? "error" : "shield", description: policy.expired ? `EXPIRED ${expires}` : `expires ${expires}` })); for (const r of policy.roots) { items.push(new Line(`Root: ${r.name || r.key.slice(0, 12)}`, { icon: "key", description: r.email, tooltip: r.key })); } items.push(new Line(`Root threshold ${policy.root_threshold}`, { icon: "law" })); for (const rule of policy.rules) { items.push(new Line(`Rule ${rule.pattern}`, { icon: "list-tree", description: `${rule.keys.map((k) => k.name || k.key.slice(0, 8)).join(", ")} · threshold ${rule.threshold} · ${rule.require}${rule.allow_conflicts ? "" : " · no conflicts"}` })); } for (const r of policy.revoked) { items.push(new Line(`Revoked: ${r.name || r.key.slice(0, 12)}`, { icon: "circle-slash" })); } } const counts = { signed: 0, sealed: 0, unsigned: 0, invalid: 0, revoked: 0 }; for (const v of verify) { if (v.status in counts) { counts[v.status as keyof typeof counts] += 1; } if (v.revoked) { counts.revoked += 1; } } items.push( new Line(`${verify.length} changes verified`, { icon: counts.invalid > 0 ? "error" : "verified", description: `${counts.signed} signed, ${counts.sealed} sealed, ${counts.unsigned} unsigned${counts.invalid ? `, ${counts.invalid} INVALID` : ""}${counts.revoked ? `, ${counts.revoked} by revoked keys` : ""}`, command: { title: "Verify", command: "oxygen.verify", arguments: [repo] }, }), ); for (const l of tlogLines(this.tlogChecks.get(repo.root), timeAgo)) { items.push(new Line(l.label, { icon: l.icon, description: l.description, tooltip: l.tooltip, command: { title: "Verify transparency log", command: "oxygen.verifyTlog", arguments: [repo] } })); } return items; } }