// The Proposals view (spec §5): per repository with a hub remote, three // buckets, a detail webview, and the actions. import * as vscode from "vscode"; import type * as api from "./hubapi"; import { HubAuth } from "./auth"; import { hubUrl, needsMe, parseRepoUrl } from "./hub"; import type { OxygenAPI, RepositoryHandle } from "./oxygenapi"; /** A repository in the workspace with a hub remote. */ export interface HubRepo { handle: RepositoryHandle; base: string; owner: string; name: string; remote: string; } /** Just enough to name a repository on a hub. */ export type RepoRef = Pick; /** Find the hub remotes of every repository the Oxygen extension knows. */ export async function hubRepos(oxygen: OxygenAPI, auth: HubAuth): Promise { const out: HubRepo[] = []; const bases = auth.bases(); for (const handle of oxygen.repositories) { let remotes: { name: string; url: string; ca?: string }[] = []; try { remotes = await handle.remotes(); } catch { continue; } for (const r of remotes) { const p = parseRepoUrl(r.url); if (p && r.ca) { auth.setCa(p.base, r.ca); } if (p && bases.includes(p.base)) { out.push({ handle, base: p.base, owner: p.owner, name: p.name, remote: r.name }); break; } } } return out; } type Node = | { kind: "repo"; repo: HubRepo } | { kind: "bucket"; repo: HubRepo; label: string; items: api.Proposal[] } | { kind: "proposal"; repo: HubRepo; p: api.Proposal } | { kind: "file"; repo: HubRepo; p: api.Proposal; f: api.ProposalFile }; const FILE_ICONS: Record = { added: "diff-added", removed: "diff-removed", renamed: "diff-renamed", modified: "diff-modified" }; export class ProposalsProvider implements vscode.TreeDataProvider { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.emitter.event; private cache = new Map(); private files = new Map(); constructor( private readonly oxygen: () => OxygenAPI | undefined, private readonly auth: HubAuth, ) {} refresh(): void { this.cache.clear(); this.files.clear(); this.emitter.fire(); } async repos(): Promise { const o = this.oxygen(); return o ? hubRepos(o, this.auth) : []; } async proposalsOf(repo: HubRepo): Promise { const key = `${repo.base}/${repo.owner}/${repo.name}`; const hit = this.cache.get(key); if (hit) { return hit; } const list = await this.auth.clientFor(repo.base).proposals(repo.owner, repo.name); this.cache.set(key, list); return list; } /** The files a proposal changes, cached until the next refresh. */ async filesOf(repo: HubRepo, p: api.Proposal): Promise { const key = `${repo.base}/${repo.owner}/${repo.name}#${p.number}`; const hit = this.files.get(key); if (hit) { return hit; } const list = await this.auth.clientFor(repo.base).proposalFiles(repo.owner, repo.name, p.number); this.files.set(key, list); return list; } getTreeItem(node: Node): vscode.TreeItem { switch (node.kind) { case "repo": { const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded); item.iconPath = new vscode.ThemeIcon("repo"); item.contextValue = "repo"; return item; } case "bucket": { const item = new vscode.TreeItem(`${node.label} (${node.items.length})`, node.items.length ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed); item.contextValue = "bucket"; return item; } case "proposal": { const p = node.p; const item = new vscode.TreeItem(`#${p.number} ${p.title}`, vscode.TreeItemCollapsibleState.Collapsed); const sealed = p.approvals.filter((a) => a.sealed).length; const chips = [p.state === "draft" ? "draft" : "", p.conflicted ? "conflicts" : "", p.landable ? "landable" : "", sealed ? `${sealed} sealed` : ""].filter(Boolean).join(" · "); item.description = `${p.author} → ${p.target}${chips ? " · " + chips : ""}`; item.tooltip = new vscode.MarkdownString(`**${p.title}** by ${p.author}\n\n${p.changes.length} change(s) into ${p.target}\n\n${p.blockers.length ? "Blocked: " + p.blockers.join("; ") : "Ready to land"}`); item.iconPath = new vscode.ThemeIcon(p.conflicted ? "warning" : p.landable ? "pass" : p.state === "draft" ? "circle-outline" : "git-pull-request"); // `proposal` or `proposalDraft`: a draft is marked ready, an open one turned back into a draft. item.contextValue = p.state === "draft" ? "proposalDraft" : "proposal"; item.command = { title: "Open", command: "oxygenhub.openProposal", arguments: [node] }; return item; } case "file": { const f = node.f; const slash = f.path.lastIndexOf("/"); const item = new vscode.TreeItem(f.path.slice(slash + 1), vscode.TreeItemCollapsibleState.None); const dir = slash > 0 ? f.path.slice(0, slash) : ""; item.description = [dir, f.from ? `from ${f.from}` : "", f.added || f.removed ? `+${f.added} −${f.removed}` : f.kind].filter(Boolean).join(" · "); item.tooltip = `${f.path} (${f.kind})`; item.iconPath = new vscode.ThemeIcon(FILE_ICONS[f.kind] ?? "diff"); item.contextValue = "proposalFile"; item.command = { title: "Open Diff", command: "oxygenhub.openProposalFile", arguments: [node] }; return item; } } } async getChildren(node?: Node): Promise { if (!node) { const repos = await this.repos(); return repos.map((repo) => ({ kind: "repo", repo })); } if (node.kind === "repo") { let list: api.Proposal[] = []; try { list = await this.proposalsOf(node.repo); } catch (e) { void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`); return []; } const me = this.auth.sessionFor(node.repo.base)?.username; const mine = list.filter((p) => p.author === me); const needing = list.filter((p) => needsMe(p, me)); const rest = list.filter((p) => !mine.includes(p) && !needing.includes(p)); return [ { kind: "bucket", repo: node.repo, label: "Needing me", items: needing }, { kind: "bucket", repo: node.repo, label: "Mine", items: mine }, { kind: "bucket", repo: node.repo, label: "Open", items: rest }, ]; } if (node.kind === "bucket") { return node.items.map((p) => ({ kind: "proposal", repo: node.repo, p })); } if (node.kind === "proposal") { try { const files = await this.filesOf(node.repo, node.p); return files.map((f) => ({ kind: "file", repo: node.repo, p: node.p, f })); } catch (e) { void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`); } } return []; } } export type ProposalNode = Extract; export type ProposalFileNode = Extract; /** The detail webview. */ export function showDetail(repo: HubRepo, p: api.Proposal, checks: api.Check[]): void { const panel = vscode.window.createWebviewPanel("oxygenhub.proposal", `#${p.number} ${p.title}`, vscode.ViewColumn.Beside, { enableCommandUris: true }); const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); const changes = p.changes.map((c) => `
  • ${c.hash.slice(0, 12)} ${esc(c.message.split("\n")[0])} ${esc(c.author.name)} · ${c.signature.status}
  • `).join(""); const approvals = p.approvals.map((a) => `
  • ${a.sealed ? "◈" : "○"} ${esc(a.account)}${a.sealed ? "" : " (seal pending)"}
  • `).join("") || "
  • none
  • "; const blockers = p.blockers.map((b) => `
  • ✘ ${esc(b)}
  • `).join("") || "
  • ✔ ready to land
  • "; const checkRows = checks.map((c) => `
  • ${c.status === "passed" ? "✔" : c.status === "failed" ? "✘" : "…"} ${esc(c.name)} ${c.status}${c.summary ? " · " + esc(c.summary) : ""}
  • `).join("") || "
  • none
  • "; const url = hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number: p.number }); const arg = encodeURIComponent(JSON.stringify([repo.base, repo.owner, repo.name, p.number])); panel.webview.html = `

    #${p.number} ${esc(p.title)} ${p.state}

    ${esc(p.author)} wants ${esc(JSON.stringify(p.source))} into ${esc(p.target)} · ${p.changes.length} change(s)${p.conflicted ? " · conflicts" : ""}

    Open on hub · View changes · Check out · Review · Seal approval · Land

    ${p.body ? `
    ${esc(p.body)}
    ` : ""}

    Landing

      ${blockers}

    Approvals

      ${approvals}

    Checks

      ${checkRows}

    Changes

      ${changes}
    `; }