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