f0b06dea Andy McCall 1 month ago
1
// Activation: sign in, find the Oxygen extension's API, register the
2
// remote provider, wire the views, the review controller and the commands.
3
4
import * as vscode from "vscode";
5
import { HubAuth } from "./auth";
6
import { ChecksBar } from "./checks";
bf73998c Andy McCall 1 month ago ↶
7
import { ProposalContentProvider, SCHEME, openAllChanges, openFileDiff, refOf } from "./diff";
f0b06dea Andy McCall 1 month ago
8
import { hubUrl } from "./hub";
b2dd082c Andy McCall 1 month ago ↶
9
import { InboxBar } from "./inbox";
f902d5b8 Andy McCall 1 month ago ↶
10
import { ReferenceCompletions, ReferenceHovers, ReferenceIndex, SELECTOR } from "./references";
95f69b4a Andy McCall 1 month ago ↶
11
import { annotateTodo, issueBody, todoTitle } from "./refs";
f0b06dea Andy McCall 1 month ago
12
import { IssueNode, IssuesProvider, showIssue } from "./issues";
13
import type { OxygenAPI } from "./oxygenapi";
bf73998c Andy McCall 1 month ago ↶
14
import { HubRepo, ProposalFileNode, ProposalNode, ProposalsProvider, showDetail } from "./proposals";
f0b06dea Andy McCall 1 month ago
15
import { makeProvider } from "./provider";
16
import { ReviewController } from "./review";
17
18
let auth: HubAuth;
19
let oxygen: OxygenAPI | undefined;
20
let proposals: ProposalsProvider;
21
let issues: IssuesProvider;
22
let review: ReviewController;
23
let bar: ChecksBar;
bf73998c Andy McCall 1 month ago ↶
24
let content: ProposalContentProvider;
b2dd082c Andy McCall 1 month ago ↶
25
let inbox: InboxBar;
f902d5b8 Andy McCall 1 month ago ↶
26
let references: ReferenceIndex;
f0b06dea Andy McCall 1 month ago
27
28
export async function activate(context: vscode.ExtensionContext): Promise<void> {
29
auth = new HubAuth(context.secrets);
30
await auth.load();
31
context.subscriptions.push(auth);
32
52cef001 Andy McCall 1 month ago ↶
33
const ext = vscode.extensions.getExtension<OxygenAPI>("oxygen.oxygen-vscode");
f0b06dea Andy McCall 1 month ago
34
oxygen = ext ? ((await ext.activate()) as OxygenAPI | undefined) : undefined;
35
if (!oxygen) {
52cef001 Andy McCall 1 month ago ↶
36
void vscode.window.showWarningMessage("Oxygenhub: the Oxygen extension is not available; install oxygen.oxygen-vscode 0.7.0 or later.");
f0b06dea Andy McCall 1 month ago
37
} else {
38
context.subscriptions.push(oxygen.registerRemoteProvider(makeProvider({ bases: () => auth.bases() })));
39
context.subscriptions.push(oxygen.onDidChangeRepositories(() => refreshAll()));
40
}
41
42
proposals = new ProposalsProvider(() => oxygen, auth);
43
issues = new IssuesProvider(() => proposals.repos(), auth);
44
review = new ReviewController(auth);
45
bar = new ChecksBar(auth, () => proposals.repos());
b2dd082c Andy McCall 1 month ago ↶
46
inbox = new InboxBar(auth, async (t) => {
47
const repo = (await proposals.repos()).find((r) => r.base === t.base && r.owner === t.owner && r.name === t.name);
48
if (!repo) {
49
return false;
50
}
51
const client = auth.clientFor(repo.base);
52
if (t.kind === "proposal") {
53
const [p, checks] = await Promise.all([client.proposal(repo.owner, repo.name, t.number), client.proposalChecks(repo.owner, repo.name, t.number).catch(() => [])]);
54
showDetail(repo, p, checks);
55
} else {
56
await showIssue(auth, repo, await client.issue(repo.owner, repo.name, t.number));
57
}
58
return true;
59
});
60
context.subscriptions.push(inbox);
f902d5b8 Andy McCall 1 month ago ↶
61
references = new ReferenceIndex(auth, () => proposals.repos());
62
context.subscriptions.push(
63
vscode.languages.registerCompletionItemProvider(SELECTOR, new ReferenceCompletions(references), "#", "@"),
64
vscode.languages.registerHoverProvider(SELECTOR, new ReferenceHovers(references)),
65
);
bf73998c Andy McCall 1 month ago ↶
66
content = new ProposalContentProvider(auth);
67
context.subscriptions.push(content, vscode.workspace.registerTextDocumentContentProvider(SCHEME, content));
f0b06dea Andy McCall 1 month ago
68
context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.proposals", proposals), vscode.window.registerTreeDataProvider("oxygenhub.issues", issues), review, bar);
69
context.subscriptions.push(auth.onDidChange(() => refreshAll()));
70
71
context.subscriptions.push(
72
vscode.commands.registerCommand("oxygenhub.signIn", async () => {
73
const repos = oxygen ? await guessHubs() : [];
74
await auth.signIn(repos[0]);
75
refreshAll();
76
}),
77
vscode.commands.registerCommand("oxygenhub.signOut", async () => {
78
const pick = await vscode.window.showQuickPick(auth.all().map((s) => s.base), { placeHolder: "Sign out of" });
79
if (pick) {
80
await auth.removeSession(pick);
81
refreshAll();
82
}
83
}),
84
vscode.commands.registerCommand("oxygenhub.refresh", () => refreshAll()),
b2dd082c Andy McCall 1 month ago ↶
85
vscode.commands.registerCommand("oxygenhub.inbox", () => inbox.show()),
f0b06dea Andy McCall 1 month ago
86
vscode.commands.registerCommand("oxygenhub.openProposal", async (node?: ProposalNode) => {
87
const n = node ?? (await pickProposal());
88
if (!n) {
89
return;
90
}
91
const checks = await auth.clientFor(n.repo.base).proposalChecks(n.repo.owner, n.repo.name, n.p.number).catch(() => []);
92
showDetail(n.repo, n.p, checks);
93
}),
94
vscode.commands.registerCommand("oxygenhub.openOnHub", (node?: ProposalNode | IssueNode) => {
95
if (!node) {
96
return;
97
}
98
const target = node.kind === "proposal" ? { kind: "proposal", number: node.p.number } : { kind: "issue", number: node.i.number };
99
void vscode.env.openExternal(vscode.Uri.parse(hubUrl(node.repo.base, node.repo.owner, node.repo.name, target)));
100
}),
bf73998c Andy McCall 1 month ago ↶
101
vscode.commands.registerCommand("oxygenhub.openProposalFile", async (node: ProposalFileNode) => {
102
await openFileDiff(node.repo, node.p.number, node.f);
103
await review.loadDiff(node.repo, node.p.number).catch(() => undefined);
104
}),
105
vscode.commands.registerCommand("oxygenhub.openProposalChanges", (...args: unknown[]) =>
106
withProposal(args, async (repo, number) => {
107
try {
108
const client = auth.clientFor(repo.base);
109
const [p, files] = await Promise.all([client.proposal(repo.owner, repo.name, number), client.proposalFiles(repo.owner, repo.name, number)]);
110
await openAllChanges(repo, p, files);
111
await review.loadDiff(repo, number);
112
} catch (e) {
113
void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
114
}
115
}),
116
),
f0b06dea Andy McCall 1 month ago
117
vscode.commands.registerCommand("oxygenhub.checkoutProposal", (...args: unknown[]) => withProposal(args, checkout)),
118
vscode.commands.registerCommand("oxygenhub.review", (...args: unknown[]) => withProposal(args, reviewProposal)),
119
vscode.commands.registerCommand("oxygenhub.sealApproval", (...args: unknown[]) => withProposal(args, (repo, number) => runInTerminal(repo, `oxy seal --proposal ${hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })}`))),
120
vscode.commands.registerCommand("oxygenhub.land", (...args: unknown[]) => withProposal(args, (repo, number) => runInTerminal(repo, `oxy proposal land ${hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })}`))),
121
vscode.commands.registerCommand("oxygenhub.closeProposal", (...args: unknown[]) =>
122
withProposal(args, async (repo, number) => {
123
void vscode.env.openExternal(vscode.Uri.parse(hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })));
124
}),
125
),
126
vscode.commands.registerCommand("oxygenhub.createProposal", () => createProposal()),
127
vscode.commands.registerCommand("oxygenhub.newIssue", () => newIssue()),
95f69b4a Andy McCall 1 month ago ↶
128
vscode.commands.registerCommand("oxygenhub.issueFromSelection", () => issueFromSelection()),
f0b06dea Andy McCall 1 month ago
129
vscode.commands.registerCommand("oxygenhub.openIssue", async (node?: IssueNode) => node && showIssue(auth, node.repo, node.i)),
130
vscode.commands.registerCommand("oxygenhub.commentIssue", (...args: unknown[]) =>
131
withIssue(args, async (repo, number) => {
132
const body = await vscode.window.showInputBox({ prompt: `Comment on #${number}` });
133
if (body) {
134
await auth.clientFor(repo.base).commentIssue(repo.owner, repo.name, number, body);
135
issues.refresh();
136
}
137
}),
138
),
139
vscode.commands.registerCommand("oxygenhub.closeIssue", (...args: unknown[]) => withIssue(args, async (repo, number) => (await auth.clientFor(repo.base).setIssueState(repo.owner, repo.name, number, "closed"), issues.refresh()))),
140
vscode.commands.registerCommand("oxygenhub.reopenIssue", (...args: unknown[]) => withIssue(args, async (repo, number) => (await auth.clientFor(repo.base).setIssueState(repo.owner, repo.name, number, "open"), issues.refresh()))),
141
vscode.commands.registerCommand("oxygenhub.assignMe", (...args: unknown[]) =>
142
withIssue(args, async (repo, number) => {
143
void vscode.window.showInformationMessage(`Oxygenhub: assigning is on the hub for now: ${hubUrl(repo.base, repo.owner, repo.name, { kind: "issue", number })}`);
144
}),
145
),
146
vscode.commands.registerCommand("oxygenhub.reply", (reply: vscode.CommentReply) => review.reply(reply)),
147
vscode.commands.registerCommand("oxygenhub.newThread", (reply: vscode.CommentReply) => review.reply(reply)),
148
vscode.commands.registerCommand("oxygenhub.resolveThread", (thread: vscode.CommentThread) => {
149
thread.state = thread.state === vscode.CommentThreadState.Resolved ? vscode.CommentThreadState.Unresolved : vscode.CommentThreadState.Resolved;
150
}),
151
);
152
bar.start();
b2dd082c Andy McCall 1 month ago ↶
153
inbox.start();
f0b06dea Andy McCall 1 month ago
154
}
155
156
function refreshAll(): void {
157
proposals.refresh();
f902d5b8 Andy McCall 1 month ago ↶
158
references.clear();
bf73998c Andy McCall 1 month ago ↶
159
refreshOpenDiffs();
f0b06dea Andy McCall 1 month ago
160
issues.refresh();
161
void bar.refresh();
b2dd082c Andy McCall 1 month ago ↶
162
void inbox.refresh();
f0b06dea Andy McCall 1 month ago
163
}
164
bf73998c Andy McCall 1 month ago ↶
165
/** Reload the sides of open proposal diffs, which may have gained changes. */
166
function refreshOpenDiffs(): void {
167
const seen = new Set<string>();
168
for (const doc of vscode.workspace.textDocuments) {
169
const r = refOf(doc.uri);
170
const key = r && `${r.base}/${r.owner}/${r.name}#${r.number}`;
171
if (r && key && !seen.has(key)) {
172
seen.add(key);
173
content.refresh(r.base, r.owner, r.name, r.number);
174
}
175
}
176
}
177
f0b06dea Andy McCall 1 month ago
178
async function guessHubs(): Promise<string[]> {
179
const out: string[] = [];
180
for (const r of oxygen?.repositories ?? []) {
181
for (const remote of await r.remotes().catch(() => [])) {
182
const m = /^(https?:\/\/[^/]+)\/[^/]+\/[^/]+\/?$/.exec(remote.url);
183
if (m && !out.includes(m[1])) {
184
out.push(m[1]);
185
}
186
}
187
}
188
return out;
189
}
190
191
async function pickProposal(): Promise<ProposalNode | undefined> {
192
const items: { label: string; description: string; node: ProposalNode }[] = [];
193
for (const repo of await proposals.repos()) {
194
for (const p of await proposals.proposalsOf(repo).catch(() => [])) {
195
items.push({ label: `#${p.number} ${p.title}`, description: `${repo.owner}/${repo.name} · ${p.author} → ${p.target}`, node: { kind: "proposal", repo, p } });
196
}
197
}
198
return (await vscode.window.showQuickPick(items, { placeHolder: "Proposal" }))?.node;
199
}
200
201
/** Commands take a tree node, or the webview's `[base, owner, name, number]`. */
202
async function withProposal(args: unknown[], f: (repo: HubRepo, number: number) => Promise<void>): Promise<void> {
203
const a = args[0];
204
if (a && typeof a === "object" && (a as ProposalNode).kind === "proposal") {
205
const n = a as ProposalNode;
206
return f(n.repo, n.p.number);
207
}
208
if (Array.isArray(a) && a.length === 4) {
209
const [base, owner, name, number] = a as [string, string, string, number];
210
const repo = (await proposals.repos()).find((r) => r.base === base && r.owner === owner && r.name === name);
211
if (repo) {
212
return f(repo, number);
213
}
214
}
215
const n = await pickProposal();
216
if (n) {
217
return f(n.repo, n.p.number);
218
}
219
}
220
221
async function withIssue(args: unknown[], f: (repo: HubRepo, number: number) => Promise<void>): Promise<void> {
222
const a = args[0];
223
if (a && typeof a === "object" && (a as IssueNode).kind === "issue") {
224
const n = a as IssueNode;
225
return f(n.repo, n.i.number);
226
}
227
if (Array.isArray(a) && a.length === 4) {
228
const [base, owner, name, number] = a as [string, string, string, number];
229
const repo = (await proposals.repos()).find((r) => r.base === base && r.owner === owner && r.name === name);
230
if (repo) {
231
return f(repo, number);
232
}
233
}
234
}
235
236
function runInTerminal(repo: HubRepo, command: string): Promise<void> {
237
const t = vscode.window.createTerminal({ name: "Oxygenhub", cwd: repo.handle.root });
238
t.show();
239
t.sendText(command);
240
return Promise.resolve();
241
}
242
243
/** Fetch the proposal's changes and switch to a local bookmark at its projected heads (spec §5). */
244
async function checkout(repo: HubRepo, number: number): Promise<void> {
245
const p = await auth.clientFor(repo.base).proposal(repo.owner, repo.name, number);
246
const prefix = vscode.workspace.getConfiguration("oxygenhub").get<string>("proposalBookmarkPrefix") ?? "proposal/";
247
const bookmark = `${prefix}${number}`;
248
try {
249
await repo.handle.json(["fetch", repo.remote, "--json"]);
250
const existing = repo.handle.bookmarks.some((b) => b.name === bookmark);
251
if (!existing) {
252
await repo.handle.json(["bookmark", "create", bookmark, ...p.projected_heads, "--json"]).catch(() => repo.handle.json(["bookmark", "create", bookmark, "--json"]));
253
}
254
await repo.handle.json(["bookmark", "set", bookmark, ...p.projected_heads, "--json"]).catch(() => undefined);
255
await repo.handle.json(["bookmark", "switch", bookmark, "--json"]);
256
await repo.handle.refresh();
257
await review.load(repo, number);
258
void vscode.window.showInformationMessage(`Oxygenhub: checked out #${number} as ${bookmark}; review threads are in place.`);
259
} catch (e) {
260
void vscode.window.showErrorMessage(`Oxygenhub: could not check out #${number}: ${(e as Error).message}`);
261
}
262
}
263
264
async function reviewProposal(repo: HubRepo, number: number): Promise<void> {
265
const verdict = await vscode.window.showQuickPick(
266
[
267
{ label: "Comment", value: "comment" as const },
268
{ label: "Approve", value: "approve" as const, description: "then seal it with oxy so it counts" },
269
{ label: "Request changes", value: "changes" as const },
270
],
271
{ placeHolder: `Review #${number}` },
272
);
273
if (!verdict) {
274
return;
275
}
276
const body = (await vscode.window.showInputBox({ prompt: "Summary (optional)" })) ?? "";
277
try {
278
await auth.clientFor(repo.base).review(repo.owner, repo.name, number, verdict.value, body);
279
} catch (e) {
280
void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
281
return;
282
}
283
proposals.refresh();
284
if (verdict.value === "approve") {
285
const pick = await vscode.window.showInformationMessage("Approval recorded. Seal it with your key so it counts?", "Seal now");
286
if (pick === "Seal now") {
287
await runInTerminal(repo, `oxy seal --proposal ${hubUrl(repo.base, repo.owner, repo.name, { kind: "proposal", number })}`);
288
}
289
}
290
}
291
292
async function createProposal(): Promise<void> {
293
const repos = await proposals.repos();
294
const repo = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r;
295
if (!repo) {
296
return;
297
}
298
const source = repo.handle.currentBookmark;
299
if (!source) {
300
void vscode.window.showWarningMessage("Oxygenhub: no current bookmark.");
301
return;
302
}
303
const remoteBookmarks = await auth.clientFor(repo.base).bookmarks(repo.owner, repo.name).catch(() => []);
304
const target = await vscode.window.showQuickPick(remoteBookmarks.filter((b) => b.name !== source).map((b) => b.name), { placeHolder: `Propose ${source} into…` });
305
if (!target) {
306
return;
307
}
308
const title = await vscode.window.showInputBox({ prompt: "Title", value: source });
309
if (!title) {
310
return;
311
}
312
try {
313
await repo.handle.json(["push", repo.remote, "--bookmark", source, "--json"]).catch(() => undefined);
314
const p = await auth.clientFor(repo.base).createProposal(repo.owner, repo.name, { title, body: "", target, source, draft: false });
315
proposals.refresh();
316
void vscode.env.openExternal(vscode.Uri.parse(p.url));
317
} catch (e) {
318
void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
319
}
320
}
321
322
async function newIssue(): Promise<void> {
323
const repos = await proposals.repos();
324
const repo = repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r;
325
if (!repo) {
326
return;
327
}
328
const title = await vscode.window.showInputBox({ prompt: "Issue title" });
329
if (!title) {
330
return;
331
}
332
const body = (await vscode.window.showInputBox({ prompt: "Description (optional)" })) ?? "";
333
try {
334
const i = await auth.clientFor(repo.base).createIssue(repo.owner, repo.name, title, body);
335
issues.refresh();
336
void vscode.window.showInformationMessage(`Oxygenhub: opened #${i.number}.`);
337
} catch (e) {
338
void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
339
}
340
}
341
95f69b4a Andy McCall 1 month ago ↶
342
/**
343
* An issue from the editor (spec §8.1): the selection, or the current line,
344
* quoted under a permalink to it on the hub. A TODO line suggests the title
345
* and afterwards records the issue's number.
346
*/
347
async function issueFromSelection(): Promise<void> {
348
const editor = vscode.window.activeTextEditor;
349
if (!editor || editor.document.uri.scheme !== "file") {
350
void vscode.window.showWarningMessage("Oxygenhub: open a file of the repository first.");
351
return;
352
}
353
const doc = editor.document;
354
const file = doc.uri.fsPath;
355
const repo = (await proposals.repos()).filter((r) => file.startsWith(r.handle.root + "/") || file.startsWith(r.handle.root + "\\")).sort((a, b) => b.handle.root.length - a.handle.root.length)[0];
356
if (!repo) {
357
void vscode.window.showWarningMessage("Oxygenhub: this file is not in a repository with a hub remote.");
358
return;
359
}
360
const sel = editor.selection;
361
const startLine = sel.start.line;
362
const endLine = sel.isEmpty ? sel.start.line : sel.end.character === 0 && sel.end.line > sel.start.line ? sel.end.line - 1 : sel.end.line;
363
const code = doc.getText(new vscode.Range(startLine, 0, endLine, doc.lineAt(endLine).text.length));
364
const todoLine = [...Array(endLine - startLine + 1).keys()].map((i) => startLine + i).find((l) => todoTitle(doc.lineAt(l).text) !== undefined);
365
const title = await vscode.window.showInputBox({ prompt: "Issue title", value: todoLine !== undefined ? todoTitle(doc.lineAt(todoLine).text) : "" });
366
if (!title) {
367
return;
368
}
369
// Pin the link to the head when the bookmark has one; otherwise follow the bookmark.
370
const bookmark = repo.handle.bookmarks.find((b) => b.name === repo.handle.currentBookmark);
371
const pinned = bookmark?.heads.length === 1 && !doc.isDirty;
372
const reference = pinned ? bookmark!.heads[0] : (repo.handle.currentBookmark ?? "main");
373
const path = file.slice(repo.handle.root.length + 1).split("\\").join("/");
374
const permalink = hubUrl(repo.base, repo.owner, repo.name, { kind: "file", reference, path, line: startLine + 1, endLine: endLine + 1 });
375
const body = issueBody({ permalink, path, startLine: startLine + 1, endLine: endLine + 1, language: doc.languageId === "plaintext" ? "" : doc.languageId, code, pinned });
376
try {
377
const client = auth.clientFor(repo.base);
378
const issue = await client.createIssue(repo.owner, repo.name, title, body);
379
issues.refresh();
380
references.clear();
381
if (todoLine !== undefined) {
382
const line = doc.lineAt(todoLine);
383
const annotated = annotateTodo(line.text, issue.number);
384
if (annotated !== line.text) {
385
const pick = await vscode.window.showInformationMessage(`Oxygenhub: opened #${issue.number}. Record it in the TODO?`, "Add (#" + issue.number + ")");
386
if (pick) {
387
await editor.edit((e) => e.replace(line.range, annotated));
388
}
389
}
390
}
391
await showIssue(auth, repo, issue);
392
} catch (e) {
393
void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
394
}
395
}
396
f0b06dea Andy McCall 1 month ago
397
export function deactivate(): void {}