| 1 |
// The Proposals view (spec §5): per repository with a hub remote, three |
| 2 |
// buckets, a detail webview, and the actions. |
| 3 |
|
| 4 |
import * as vscode from "vscode"; |
| 5 |
import type * as api from "./hubapi"; |
| 6 |
import { HubAuth } from "./auth"; |
| 7 |
import { hubUrl, needsMe, parseRepoUrl } from "./hub"; |
| 8 |
import type { OxygenAPI, RepositoryHandle } from "./oxygenapi"; |
| 9 |
|
| 10 |
/** A repository in the workspace with a hub remote. */ |
| 11 |
export interface HubRepo { |
| 12 |
handle: RepositoryHandle; |
| 13 |
base: string; |
| 14 |
owner: string; |
| 15 |
name: string; |
| 16 |
remote: string; |
| 17 |
} |
| 18 |
|
| 19 |
/** Just enough to name a repository on a hub. */ |
| 20 |
export type RepoRef = Pick<HubRepo, "base" | "owner" | "name">; |
| 21 |
|
| 22 |
/** Find the hub remotes of every repository the Oxygen extension knows. */ |
| 23 |
export async function hubRepos(oxygen: OxygenAPI, auth: HubAuth): Promise<HubRepo[]> { |
| 24 |
const out: HubRepo[] = []; |
| 25 |
const bases = auth.bases(); |
| 26 |
for (const handle of oxygen.repositories) { |
| 27 |
let remotes: { name: string; url: string; ca?: string }[] = []; |
| 28 |
try { |
| 29 |
remotes = await handle.remotes(); |
| 30 |
} catch { |
| 31 |
continue; |
| 32 |
} |
| 33 |
for (const r of remotes) { |
| 34 |
const p = parseRepoUrl(r.url); |
| 35 |
if (p && r.ca) { |
| 36 |
auth.setCa(p.base, r.ca); |
| 37 |
} |
| 38 |
if (p && bases.includes(p.base)) { |
| 39 |
out.push({ handle, base: p.base, owner: p.owner, name: p.name, remote: r.name }); |
| 40 |
break; |
| 41 |
} |
| 42 |
} |
| 43 |
} |
| 44 |
return out; |
| 45 |
} |
| 46 |
|
| 47 |
type Node = |
| 48 |
| { kind: "repo"; repo: HubRepo } |
| 49 |
| { kind: "bucket"; repo: HubRepo; label: string; items: api.Proposal[] } |
| 50 |
| { kind: "proposal"; repo: HubRepo; p: api.Proposal } |
| 51 |
| { kind: "file"; repo: HubRepo; p: api.Proposal; f: api.ProposalFile }; |
| 52 |
|
| 53 |
const FILE_ICONS: Record<string, string> = { added: "diff-added", removed: "diff-removed", renamed: "diff-renamed", modified: "diff-modified" }; |
| 54 |
|
| 55 |
export class ProposalsProvider implements vscode.TreeDataProvider<Node> { |
| 56 |
private readonly emitter = new vscode.EventEmitter<void>(); |
| 57 |
readonly onDidChangeTreeData = this.emitter.event; |
| 58 |
private cache = new Map<string, api.Proposal[]>(); |
| 59 |
private files = new Map<string, api.ProposalFile[]>(); |
| 60 |
|
| 61 |
constructor( |
| 62 |
private readonly oxygen: () => OxygenAPI | undefined, |
| 63 |
private readonly auth: HubAuth, |
| 64 |
) {} |
| 65 |
|
| 66 |
refresh(): void { |
| 67 |
this.cache.clear(); |
| 68 |
this.files.clear(); |
| 69 |
this.emitter.fire(); |
| 70 |
} |
| 71 |
|
| 72 |
async repos(): Promise<HubRepo[]> { |
| 73 |
const o = this.oxygen(); |
| 74 |
return o ? hubRepos(o, this.auth) : []; |
| 75 |
} |
| 76 |
|
| 77 |
async proposalsOf(repo: HubRepo): Promise<api.Proposal[]> { |
| 78 |
const key = `${repo.base}/${repo.owner}/${repo.name}`; |
| 79 |
const hit = this.cache.get(key); |
| 80 |
if (hit) { |
| 81 |
return hit; |
| 82 |
} |
| 83 |
const list = await this.auth.clientFor(repo.base).proposals(repo.owner, repo.name); |
| 84 |
this.cache.set(key, list); |
| 85 |
return list; |
| 86 |
} |
| 87 |
|
| 88 |
/** The files a proposal changes, cached until the next refresh. */ |
| 89 |
async filesOf(repo: HubRepo, p: api.Proposal): Promise<api.ProposalFile[]> { |
| 90 |
const key = `${repo.base}/${repo.owner}/${repo.name}#${p.number}`; |
| 91 |
const hit = this.files.get(key); |
| 92 |
if (hit) { |
| 93 |
return hit; |
| 94 |
} |
| 95 |
const list = await this.auth.clientFor(repo.base).proposalFiles(repo.owner, repo.name, p.number); |
| 96 |
this.files.set(key, list); |
| 97 |
return list; |
| 98 |
} |
| 99 |
|
| 100 |
getTreeItem(node: Node): vscode.TreeItem { |
| 101 |
switch (node.kind) { |
| 102 |
case "repo": { |
| 103 |
const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded); |
| 104 |
item.iconPath = new vscode.ThemeIcon("repo"); |
| 105 |
item.contextValue = "repo"; |
| 106 |
return item; |
| 107 |
} |
| 108 |
case "bucket": { |
| 109 |
const item = new vscode.TreeItem(`${node.label} (${node.items.length})`, node.items.length ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed); |
| 110 |
item.contextValue = "bucket"; |
| 111 |
return item; |
| 112 |
} |
| 113 |
case "proposal": { |
| 114 |
const p = node.p; |
| 115 |
const item = new vscode.TreeItem(`#${p.number} ${p.title}`, vscode.TreeItemCollapsibleState.Collapsed); |
| 116 |
const sealed = p.approvals.filter((a) => a.sealed).length; |
| 117 |
const chips = [p.state === "draft" ? "draft" : "", p.conflicted ? "conflicts" : "", p.landable ? "landable" : "", sealed ? `${sealed} sealed` : ""].filter(Boolean).join(" · "); |
| 118 |
item.description = `${p.author} → ${p.target}${chips ? " · " + chips : ""}`; |
| 119 |
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"}`); |
| 120 |
item.iconPath = new vscode.ThemeIcon(p.conflicted ? "warning" : p.landable ? "pass" : p.state === "draft" ? "circle-outline" : "git-pull-request"); |
| 121 |
// `proposal` or `proposalDraft`: a draft is marked ready, an open one turned back into a draft. |
| 122 |
item.contextValue = p.state === "draft" ? "proposalDraft" : "proposal"; |
| 123 |
item.command = { title: "Open", command: "oxygenhub.openProposal", arguments: [node] }; |
| 124 |
return item; |
| 125 |
} |
| 126 |
case "file": { |
| 127 |
const f = node.f; |
| 128 |
const slash = f.path.lastIndexOf("/"); |
| 129 |
const item = new vscode.TreeItem(f.path.slice(slash + 1), vscode.TreeItemCollapsibleState.None); |
| 130 |
const dir = slash > 0 ? f.path.slice(0, slash) : ""; |
| 131 |
item.description = [dir, f.from ? `from ${f.from}` : "", f.added || f.removed ? `+${f.added} −${f.removed}` : f.kind].filter(Boolean).join(" · "); |
| 132 |
item.tooltip = `${f.path} (${f.kind})`; |
| 133 |
item.iconPath = new vscode.ThemeIcon(FILE_ICONS[f.kind] ?? "diff"); |
| 134 |
item.contextValue = "proposalFile"; |
| 135 |
item.command = { title: "Open Diff", command: "oxygenhub.openProposalFile", arguments: [node] }; |
| 136 |
return item; |
| 137 |
} |
| 138 |
} |
| 139 |
} |
| 140 |
|
| 141 |
async getChildren(node?: Node): Promise<Node[]> { |
| 142 |
if (!node) { |
| 143 |
const repos = await this.repos(); |
| 144 |
return repos.map((repo) => ({ kind: "repo", repo })); |
| 145 |
} |
| 146 |
if (node.kind === "repo") { |
| 147 |
let list: api.Proposal[] = []; |
| 148 |
try { |
| 149 |
list = await this.proposalsOf(node.repo); |
| 150 |
} catch (e) { |
| 151 |
void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`); |
| 152 |
return []; |
| 153 |
} |
| 154 |
const me = this.auth.sessionFor(node.repo.base)?.username; |
| 155 |
const mine = list.filter((p) => p.author === me); |
| 156 |
const needing = list.filter((p) => needsMe(p, me)); |
| 157 |
const rest = list.filter((p) => !mine.includes(p) && !needing.includes(p)); |
| 158 |
return [ |
| 159 |
{ kind: "bucket", repo: node.repo, label: "Needing me", items: needing }, |
| 160 |
{ kind: "bucket", repo: node.repo, label: "Mine", items: mine }, |
| 161 |
{ kind: "bucket", repo: node.repo, label: "Open", items: rest }, |
| 162 |
]; |
| 163 |
} |
| 164 |
if (node.kind === "bucket") { |
| 165 |
return node.items.map((p) => ({ kind: "proposal", repo: node.repo, p })); |
| 166 |
} |
| 167 |
if (node.kind === "proposal") { |
| 168 |
try { |
| 169 |
const files = await this.filesOf(node.repo, node.p); |
| 170 |
return files.map((f) => ({ kind: "file", repo: node.repo, p: node.p, f })); |
| 171 |
} catch (e) { |
| 172 |
void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`); |
| 173 |
} |
| 174 |
} |
| 175 |
return []; |
| 176 |
} |
| 177 |
} |
| 178 |
|
| 179 |
export type ProposalNode = Extract<Node, { kind: "proposal" }>; |
| 180 |
export type ProposalFileNode = Extract<Node, { kind: "file" }>; |
| 181 |
|
| 182 |
/** The detail webview. */ |
| 183 |
export function showDetail(repo: HubRepo, p: api.Proposal, checks: api.Check[]): void { |
| 184 |
const panel = vscode.window.createWebviewPanel("oxygenhub.proposal", `#${p.number} ${p.title}`, vscode.ViewColumn.Beside, { enableCommandUris: true }); |
| 185 |
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 186 |
const changes = p.changes.map((c) => `<li><code>${c.hash.slice(0, 12)}</code> ${esc(c.message.split("\n")[0])} <span class="mute">${esc(c.author.name)} · ${c.signature.status}</span></li>`).join(""); |
| 187 |
const approvals = p.approvals.map((a) => `<li>${a.sealed ? "◈" : "○"} ${esc(a.account)}${a.sealed ? "" : " (seal pending)"}</li>`).join("") || "<li class=mute>none</li>"; |
| 188 |
const blockers = p.blockers.map((b) => `<li>✘ ${esc(b)}</li>`).join("") || "<li>✔ ready to land</li>"; |
| 189 |
const checkRows = checks.map((c) => `<li>${c.status === "passed" ? "✔" : c.status === "failed" ? "✘" : "…"} ${esc(c.name)} <span class=mute>${c.status}${c.summary ? " · " + esc(c.summary) : ""}</span></li>`).join("") || "<li class=mute>none</li>"; |
| 190 |
const url = hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number: p.number }); |
| 191 |
const arg = encodeURIComponent(JSON.stringify([repo.base, repo.owner, repo.name, p.number])); |
| 192 |
panel.webview.html = `<!doctype html><html><body style="font-family: var(--vscode-font-family); padding: 12px"> |
| 193 |
<h2>#${p.number} ${esc(p.title)} <small>${p.state}</small></h2> |
| 194 |
<p>${esc(p.author)} wants <b>${esc(JSON.stringify(p.source))}</b> into <b>${esc(p.target)}</b> · ${p.changes.length} change(s)${p.conflicted ? " · <b>conflicts</b>" : ""}</p> |
| 195 |
<p><a href="${url}">Open on hub</a> · <a href="command:oxygenhub.openProposalChanges?${arg}">View changes</a> · <a href="command:oxygenhub.checkoutProposal?${arg}">Check out</a> · <a href="command:oxygenhub.review?${arg}">Review</a> · <a href="command:oxygenhub.sealApproval?${arg}">Seal approval</a> · <a href="command:oxygenhub.land?${arg}">Land</a></p> |
| 196 |
<div>${p.body ? `<pre style="white-space: pre-wrap">${esc(p.body)}</pre>` : ""}</div> |
| 197 |
<h3>Landing</h3><ul>${blockers}</ul> |
| 198 |
<h3>Approvals</h3><ul>${approvals}</ul> |
| 199 |
<h3>Checks</h3><ul>${checkRows}</ul> |
| 200 |
<h3>Changes</h3><ul>${changes}</ul> |
| 201 |
<style>.mute{opacity:.7} small{font-weight:normal;opacity:.7}</style> |
| 202 |
</body></html>`; |
| 203 |
} |