OXYGENHUB
# ab81294a9ba9 oxygen-vscode / src / extension.ts
30.8 KiB · last changed Milestone 0.4: history graph webview with per-change actions, file history diffs between versions, inline blame aligned to unsaved edits with a Myers line diff 1 month ago source blame history raw
1
// Activation: find oxy, find repositories, wire the source control view,
2
// decorations, the content provider, and the commands (spec §3).
3
4
import * as vscode from "vscode";
5
import { basename } from "node:path";
6
import { Oxy, OxyError, findBinary } from "./oxy";
7
import { Repository, OxyResourceState } from "./repository";
8
import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider";
9
import { OxyDecorationProvider } from "./decorations";
10
import { OperationItem, OperationsProvider, timeAgo } from "./operations";
11
import { BlameController } from "./blame";
12
import { HistoryPanel } from "./history";
13
14
const MIN_VERSION = { format: 2, protocol: 1 };
15
16
let output: vscode.OutputChannel;
17
let oxy: Oxy;
18
let contentProvider: OxyContentProvider;
19
let decorations: OxyDecorationProvider;
20
let operations: OperationsProvider;
21
let blame: BlameController;
22
const repositories = new Map<string, Repository>();
23
24
export async function activate(context: vscode.ExtensionContext): Promise<void> {
25
  output = vscode.window.createOutputChannel("Oxygen");
26
  context.subscriptions.push(output);
27
  output.appendLine(`Oxygen extension ${context.extension.packageJSON.version} activating; folders: ${(vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath).join(", ") || "(none)"}`);
28
  oxy = new Oxy(findBinary(vscode.workspace.getConfiguration("oxygen").get<string>("path")), (line) => output.appendLine(line));
29
30
  try {
31
    const v = await oxy.version();
32
    output.appendLine(`oxy ${v.version} (format ${v.format}, protocol ${v.protocol}) at ${oxy.binary}`);
33
    if (v.format < MIN_VERSION.format || v.protocol < MIN_VERSION.protocol) {
34
      void vscode.window.showWarningMessage(`Oxygen: oxy ${v.version} is older than this extension supports.`);
35
    }
36
  } catch (e) {
37
    const pick = await vscode.window.showErrorMessage(`Oxygen: could not run oxy (${(e as Error).message}). Set "oxygen.path" or put oxy on your PATH.`, "Open Settings");
38
    if (pick === "Open Settings") {
39
      void vscode.commands.executeCommand("workbench.action.openSettings", "oxygen.path");
40
    }
41
    return;
42
  }
43
44
  contentProvider = new OxyContentProvider(oxy);
45
  context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider));
46
  decorations = new OxyDecorationProvider(() => [...repositories.values()]);
47
  context.subscriptions.push(decorations);
48
  operations = new OperationsProvider(() => [...repositories.values()]);
49
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations));
50
  blame = new BlameController(() => [...repositories.values()]);
51
  context.subscriptions.push(blame);
52
53
  context.subscriptions.push(
54
    vscode.commands.registerCommand("oxygen.refresh", async () => {
55
      await Promise.all([...repositories.values()].map((r) => r.refresh()));
56
    }),
57
    vscode.commands.registerCommand("oxygen.showOutput", () => output.show()),
58
    vscode.commands.registerCommand("oxygen.openFile", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
59
    vscode.commands.registerCommand("oxygen.openChanges", (state?: OxyResourceState) => state && openChanges(state)),
60
    vscode.commands.registerCommand("oxygen.openBaseVersion", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", baseUri(state.repository.root, state.path))),
61
    vscode.commands.registerCommand("oxygen.commit", (arg?: Repository | vscode.SourceControl) => commit(arg)),
62
    vscode.commands.registerCommand("oxygen.showLog", () => showLog()),
63
    vscode.commands.registerCommand("oxygen.init", () => initRepository()),
64
    vscode.commands.registerCommand("oxygen.discard", (state?: OxyResourceState, states?: OxyResourceState[]) => discard(states?.length ? states : state ? [state] : [])),
65
    vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])),
66
    vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)),
67
    vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)),
68
    vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
69
    vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
70
    vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
71
    vscode.commands.registerCommand("oxygen.switchBookmark", (arg?: Repository | vscode.SourceControl) => switchBookmark(arg)),
72
    vscode.commands.registerCommand("oxygen.createBookmark", (arg?: Repository | vscode.SourceControl) => createBookmark(arg)),
73
    vscode.commands.registerCommand("oxygen.mergeBookmark", (arg?: Repository | vscode.SourceControl) => mergeBookmark(arg)),
74
    vscode.commands.registerCommand("oxygen.deleteBookmark", (arg?: Repository | vscode.SourceControl) => deleteBookmark(arg)),
75
    vscode.commands.registerCommand("oxygen.fetch", (arg?: Repository | vscode.SourceControl) => fetch(arg)),
76
    vscode.commands.registerCommand("oxygen.pull", (arg?: Repository | vscode.SourceControl) => pull(arg)),
77
    vscode.commands.registerCommand("oxygen.push", (arg?: Repository | vscode.SourceControl) => push(arg, false)),
78
    vscode.commands.registerCommand("oxygen.pushBookmark", (arg?: Repository | vscode.SourceControl) => push(arg, true)),
79
    vscode.commands.registerCommand("oxygen.sync", (arg?: Repository | vscode.SourceControl) => sync(arg)),
80
    vscode.commands.registerCommand("oxygen.showHistory", (arg?: Repository | vscode.SourceControl) => {
81
      const repo = repositoryFor(arg);
82
      if (repo) {
83
        HistoryPanel.show(repo, (hash) => void showChange(repo.root, hash));
84
      }
85
    }),
86
    vscode.commands.registerCommand("oxygen.fileHistory", (uri?: vscode.Uri) => fileHistory(uri ?? vscode.window.activeTextEditor?.document.uri)),
87
    vscode.commands.registerCommand("oxygen.toggleBlame", () => blame.toggle()),
88
    vscode.commands.registerCommand("oxygen.showChange", (root?: string, hash?: string) => root && hash && showChange(root, hash)),
89
    vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
90
    vscode.workspace.onDidChangeConfiguration((e) => {
91
      if (e.affectsConfiguration("oxygen.path")) {
92
        void vscode.window.showInformationMessage("Oxygen: reload the window to use the new oxy path.");
93
      }
94
      if (e.affectsConfiguration("oxygen")) {
95
        decorations.refresh();
96
        for (const r of repositories.values()) {
97
          r.scheduleRefresh();
98
        }
99
      }
100
    }),
101
  );
102
  context.subscriptions.push({ dispose: () => repositories.forEach((r) => r.dispose()) });
103
104
  await discover();
105
}
106
107
export function deactivate(): void {
108
  for (const r of repositories.values()) {
109
    r.dispose();
110
  }
111
  repositories.clear();
112
}
113
114
/** One Repository per distinct oxy root among the workspace folders. */
115
async function discover(): Promise<void> {
116
  const wanted = new Map<string, vscode.WorkspaceFolder>();
117
  for (const folder of vscode.workspace.workspaceFolders ?? []) {
118
    const info = await oxy.root(folder.uri.fsPath);
119
    if (info) {
120
      wanted.set(info.root, folder);
121
    }
122
  }
123
  for (const [root, repo] of repositories) {
124
    if (!wanted.has(root)) {
125
      repo.dispose();
126
      repositories.delete(root);
127
    }
128
  }
129
  if (wanted.size === 0 && (vscode.workspace.workspaceFolders ?? []).length > 0) {
130
    output.appendLine("no Oxygen repository in the open folders (oxy root found none)");
131
  }
132
  for (const [root, folder] of wanted) {
133
    if (!repositories.has(root)) {
134
      const repo = new Repository(oxy, root, folder);
135
      repo.onDidChangeStatus(() => {
136
        decorations.refresh();
137
        contentProvider.invalidate(root);
138
        operations.refresh();
139
        blame.invalidate(root);
140
        HistoryPanel.refresh(root);
141
      });
142
      repositories.set(root, repo);
143
      output.appendLine(`repository ${root}`);
144
      await repo.refresh();
145
    }
146
  }
147
  void vscode.commands.executeCommand("setContext", "oxygen.hasRepository", repositories.size > 0);
148
}
149
150
function repositoryFor(arg: Repository | vscode.SourceControl | undefined): Repository | undefined {
151
  if (arg instanceof Repository) {
152
    return arg;
153
  }
154
  if (arg && "rootUri" in arg && arg.rootUri) {
155
    return repositories.get(arg.rootUri.fsPath);
156
  }
157
  const active = vscode.window.activeTextEditor?.document.uri;
158
  for (const repo of repositories.values()) {
159
    if (active && repo.relativePath(active) !== undefined) {
160
      return repo;
161
    }
162
  }
163
  return repositories.size === 1 ? [...repositories.values()][0] : undefined;
164
}
165
166
async function openChanges(state: OxyResourceState): Promise<void> {
167
  const repo = state.repository;
168
  const title = `${basename(state.path)} (base ↔ working copy)`;
169
  if (state.status === "Added") {
170
    await vscode.commands.executeCommand("vscode.open", state.resourceUri);
171
    return;
172
  }
173
  if (state.status === "Removed") {
174
    await vscode.commands.executeCommand("vscode.open", baseUri(repo.root, state.path));
175
    return;
176
  }
177
  await vscode.commands.executeCommand("vscode.diff", baseUri(repo.root, state.path), state.resourceUri, title);
178
}
179
180
async function commit(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
181
  const repo = repositoryFor(arg);
182
  if (!repo) {
183
    void vscode.window.showErrorMessage("Oxygen: no repository for the active file.");
184
    return;
185
  }
186
  let message = repo.sourceControl.inputBox.value.trim();
187
  if (!message) {
188
    message = (await vscode.window.showInputBox({ prompt: "Commit message", placeHolder: "What did you change?" }))?.trim() ?? "";
189
  }
190
  if (!message) {
191
    return;
192
  }
193
  try {
194
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message));
195
    repo.sourceControl.inputBox.value = "";
196
    await repo.refresh();
197
  } catch (e) {
198
    showError(e);
199
  }
200
}
201
202
async function initRepository(): Promise<void> {
203
  const folders = vscode.workspace.workspaceFolders ?? [];
204
  if (folders.length === 0) {
205
    void vscode.window.showErrorMessage("Oxygen: open a folder first.");
206
    return;
207
  }
208
  const folder = folders.length === 1 ? folders[0] : await vscode.window.showWorkspaceFolderPick({ placeHolder: "Folder to initialize as an Oxygen repository" });
209
  if (!folder) {
210
    return;
211
  }
212
  try {
213
    await oxy.check(folder.uri.fsPath, ["init"]);
214
    await discover();
215
    void vscode.window.showInformationMessage(`Oxygen: initialized ${folder.name}.`);
216
  } catch (e) {
217
    showError(e);
218
  }
219
}
220
221
async function discard(states: OxyResourceState[]): Promise<void> {
222
  if (states.length === 0) {
223
    return;
224
  }
225
  const repo = states[0].repository;
226
  const names = states.length === 1 ? basename(states[0].path) : `${states.length} files`;
227
  const pick = await vscode.window.showWarningMessage(`Discard changes in ${names}? This cannot be undone with oxy undo.`, { modal: true }, "Discard");
228
  if (pick !== "Discard") {
229
    return;
230
  }
231
  try {
232
    await repo.oxy.discard(repo.root, states.map((s) => s.path));
233
    await repo.refresh();
234
  } catch (e) {
235
    showError(e);
236
  }
237
}
238
239
async function commitSelected(states: OxyResourceState[]): Promise<void> {
240
  if (states.length === 0) {
241
    return;
242
  }
243
  const repo = states[0].repository;
244
  const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`);
245
  if (!message) {
246
    return;
247
  }
248
  try {
249
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path)));
250
    repo.sourceControl.inputBox.value = "";
251
    await repo.refresh();
252
  } catch (e) {
253
    showError(e);
254
  }
255
}
256
257
async function commitHunks(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
258
  const repo = repositoryFor(arg);
259
  if (!repo) {
260
    return;
261
  }
262
  let hunks;
263
  try {
264
    hunks = await repo.oxy.hunks(repo.root);
265
  } catch (e) {
266
    showError(e);
267
    return;
268
  }
269
  if (hunks.length === 0) {
270
    void vscode.window.showInformationMessage("Oxygen: nothing to commit.");
271
    return;
272
  }
273
  const picks = await vscode.window.showQuickPick(
274
    hunks.map((h) => ({
275
      label: `$(diff) ${h.path}`,
276
      description: `#${h.index}`,
277
      detail: h.text.split("\n").filter((l) => l.startsWith("+") || l.startsWith("-") || l.startsWith("A ") || l.startsWith("D ") || l.startsWith("R ")).slice(0, 3).join("  ").slice(0, 160),
278
      hunk: h,
279
      picked: false,
280
    })),
281
    { canPickMany: true, placeHolder: "Choose the hunks to commit; anything a chosen hunk needs comes along" },
282
  );
283
  if (!picks || picks.length === 0) {
284
    return;
285
  }
286
  const message = await askMessage(repo, `Commit ${picks.length} hunk${picks.length === 1 ? "" : "s"}`);
287
  if (!message) {
288
    return;
289
  }
290
  try {
291
    await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index));
292
    repo.sourceControl.inputBox.value = "";
293
    await repo.refresh();
294
  } catch (e) {
295
    showError(e);
296
  }
297
}
298
299
async function amend(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
300
  const repo = repositoryFor(arg);
301
  if (!repo) {
302
    return;
303
  }
304
  const message = repo.sourceControl.inputBox.value.trim();
305
  try {
306
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined));
307
    repo.sourceControl.inputBox.value = "";
308
    await repo.refresh();
309
  } catch (e) {
310
    showError(e);
311
  }
312
}
313
314
async function undo(arg: Repository | vscode.SourceControl | undefined, steps: number): Promise<void> {
315
  const repo = repositoryFor(arg);
316
  if (!repo || steps <= 0) {
317
    return;
318
  }
319
  if (steps > 1) {
320
    const pick = await vscode.window.showWarningMessage(`Undo the last ${steps} operations?`, { modal: true }, "Undo");
321
    if (pick !== "Undo") {
322
      return;
323
    }
324
  }
325
  try {
326
    let last = "";
327
    for (let i = 0; i < steps; i++) {
328
      last = await repo.oxy.undo(repo.root);
329
    }
330
    await repo.refresh();
331
    vscode.window.setStatusBarMessage(`$(discard) ${last || "Undone"}`, 4000);
332
  } catch (e) {
333
    showError(e);
334
  }
335
}
336
337
// ---------------------------------------------------------------- history
338
339
async function showChange(root: string, hash: string): Promise<void> {
340
  const repo = repositories.get(root);
341
  if (!repo) {
342
    return;
343
  }
344
  let info;
345
  try {
346
    info = await repo.oxy.show(repo.root, `#${hash.slice(0, 12)}`);
347
  } catch (e) {
348
    showError(e);
349
    return;
350
  }
351
  const subject = info.message.split("\n")[0];
352
  type Item = vscode.QuickPickItem & { action: string; path?: string };
353
  const items: Item[] = [
354
    { label: "$(diff) Show diff", description: `${info.files.length} file${info.files.length === 1 ? "" : "s"}`, action: "diff" },
355
    { label: "$(arrow-right) Check out this state", description: "the change and its dependencies", action: "checkout" },
356
    { label: "$(edit) Describe…", description: "change the message", action: "describe" },
357
    { label: "$(tag) Tag…", action: "tag" },
358
    { label: "$(trash) Abandon", description: "drop the change; its content stays on disk if the working copy sat on it", action: "abandon" },
359
    { label: "$(copy) Copy ChangeId", action: "copy" },
360
    ...info.files.map((f) => ({ label: `$(file) ${f.path}`, description: `${f.kind}${f.from ? ` from ${f.from}` : ""}`, action: "file", path: f.path })),
361
  ];
362
  const pick = await vscode.window.showQuickPick(items, {
363
    title: `${info.change_id.slice(0, 8)}  ${subject}`,
364
    placeHolder: `${info.author} · ${timeAgo(info.when_ms)} · ${info.signature?.status ?? "unsigned"}${info.signature?.revoked ? " (key revoked)" : ""} · ${info.deps.length} dependenc${info.deps.length === 1 ? "y" : "ies"}`,
365
    matchOnDescription: true,
366
  });
367
  if (!pick) {
368
    return;
369
  }
370
  try {
371
    switch (pick.action) {
372
      case "diff":
373
      case "file": {
374
        const text = await repo.oxy.diffText(repo.root, `#${hash.slice(0, 12)}`);
375
        const body = `# ${info.change_id.slice(0, 8)} ${subject}\n# ${info.author} · ${new Date(info.when_ms).toLocaleString()}\n\n${text}`;
376
        const doc = await vscode.workspace.openTextDocument({ content: body, language: "diff" });
377
        await vscode.window.showTextDocument(doc, { preview: true });
378
        return;
379
      }
380
      case "checkout":
381
        await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`]);
382
        break;
383
      case "describe": {
384
        const message = await vscode.window.showInputBox({ prompt: "New message", value: info.message });
385
        if (!message?.trim()) {
386
          return;
387
        }
388
        await repo.oxy.describe(repo.root, `#${hash.slice(0, 12)}`, message.trim());
389
        break;
390
      }
391
      case "tag": {
392
        const name = await vscode.window.showInputBox({ prompt: "Tag name" });
393
        if (!name?.trim()) {
394
          return;
395
        }
396
        await repo.oxy.tagCreate(repo.root, name.trim(), [`#${hash.slice(0, 12)}`]);
397
        break;
398
      }
399
      case "abandon": {
400
        const confirm = await vscode.window.showWarningMessage(`Abandon "${subject}"?`, { modal: true }, "Abandon");
401
        if (confirm !== "Abandon") {
402
          return;
403
        }
404
        const out = await repo.oxy.abandon(repo.root, `#${hash.slice(0, 12)}`);
405
        vscode.window.setStatusBarMessage(`$(trash) ${out.split("\n")[0]}`, 5000);
406
        break;
407
      }
408
      case "copy":
409
        await vscode.env.clipboard.writeText(info.change_id);
410
        return;
411
    }
412
    await repo.refresh();
413
  } catch (e) {
414
    if (e instanceof OxyError && e.message.includes("has changes")) {
415
      const confirm = await vscode.window.showWarningMessage("The working copy has uncommitted changes. Discard them and check out?", { modal: true }, "Discard and check out");
416
      if (confirm) {
417
        try {
418
          await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`], true);
419
          await repo.refresh();
420
        } catch (e2) {
421
          showError(e2);
422
        }
423
      }
424
      return;
425
    }
426
    showError(e);
427
  }
428
}
429
430
async function fileHistory(uri: vscode.Uri | undefined): Promise<void> {
431
  if (!uri) {
432
    return;
433
  }
434
  const repo = [...repositories.values()].find((r) => r.relativePath(uri) !== undefined);
435
  const rel = repo?.relativePath(uri);
436
  if (!repo || !rel) {
437
    void vscode.window.showInformationMessage("Oxygen: this file is not in an Oxygen repository.");
438
    return;
439
  }
440
  try {
441
    const log = await repo.oxy.log(repo.root, 200, rel);
442
    const changes = log.changes.filter((c) => !c.working_copy);
443
    if (changes.length === 0) {
444
      void vscode.window.showInformationMessage(`Oxygen: no committed change touches ${rel}.`);
445
      return;
446
    }
447
    const pick = await vscode.window.showQuickPick(
448
      changes.map((c, i) => ({
449
        label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
450
        description: `${c.change_id.slice(0, 8)} · ${c.author} · ${timeAgo(c.when_ms)}`,
451
        change: c,
452
        previous: changes[i + 1],
453
      })),
454
      { placeHolder: `${changes.length} change${changes.length === 1 ? "" : "s"} touched ${rel}; pick one to compare with the version before it`, matchOnDescription: true },
455
    );
456
    if (!pick) {
457
      return;
458
    }
459
    const after = oxyUriFor(repo.root, rel, pick.change.hash);
460
    if (pick.previous) {
461
      const before = oxyUriFor(repo.root, rel, pick.previous.hash);
462
      await vscode.commands.executeCommand("vscode.diff", before, after, `${basename(rel)} (${pick.previous.change_id.slice(0, 8)} ↔ ${pick.change.change_id.slice(0, 8)})`);
463
    } else {
464
      await vscode.commands.executeCommand("vscode.open", after);
465
    }
466
  } catch (e) {
467
    showError(e);
468
  }
469
}
470
471
function oxyUriFor(root: string, rel: string, hash: string): vscode.Uri {
472
  return oxyUri(root, rel, `#${hash.slice(0, 12)}`);
473
}
474
475
// ---------------------------------------------------------------- bookmarks
476
477
async function switchBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
478
  const repo = repositoryFor(arg);
479
  if (!repo) {
480
    return;
481
  }
482
  let bookmarks;
483
  try {
484
    bookmarks = await repo.oxy.bookmarks(repo.root);
485
  } catch (e) {
486
    showError(e);
487
    return;
488
  }
489
  type Item = vscode.QuickPickItem & { action: "switch" | "create" | "merge" | "delete"; name?: string };
490
  const items: Item[] = bookmarks.map((b) => ({
491
    label: `${b.current ? "$(circle-filled)" : "$(bookmark)"} ${b.name}`,
492
    description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}${b.ahead ? ` $(arrow-up)${b.ahead}` : ""}${b.behind ? ` $(arrow-down)${b.behind}` : ""}`,
493
    detail: b.current ? "current bookmark" : undefined,
494
    action: "switch",
495
    name: b.name,
496
  }));
497
  items.push(
498
    { label: "$(add) Create bookmark…", action: "create", alwaysShow: true },
499
    { label: "$(git-merge) Merge a bookmark into the current one…", action: "merge", alwaysShow: true },
500
    { label: "$(trash) Delete a bookmark…", action: "delete", alwaysShow: true },
501
  );
502
  const pick = await vscode.window.showQuickPick(items, { placeHolder: "Switch to a bookmark" });
503
  if (!pick) {
504
    return;
505
  }
506
  switch (pick.action) {
507
    case "create":
508
      return createBookmark(repo);
509
    case "merge":
510
      return mergeBookmark(repo);
511
    case "delete":
512
      return deleteBookmark(repo);
513
    case "switch":
514
      if (pick.name && !bookmarks.find((b) => b.name === pick.name)?.current) {
515
        await doSwitch(repo, pick.name);
516
      }
517
  }
518
}
519
520
async function doSwitch(repo: Repository, name: string): Promise<void> {
521
  try {
522
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${name}` }, () => repo.oxy.bookmarkSwitch(repo.root, name));
523
  } catch (e) {
524
    if (e instanceof OxyError && e.message.includes("has changes")) {
525
      const pick = await vscode.window.showWarningMessage(`The working copy has uncommitted changes. Switching to ${name} would discard them.`, { modal: true }, "Discard and switch");
526
      if (pick === "Discard and switch") {
527
        try {
528
          await repo.oxy.bookmarkSwitch(repo.root, name, true);
529
        } catch (e2) {
530
          showError(e2);
531
        }
532
      }
533
    } else {
534
      showError(e);
535
    }
536
  }
537
  await repo.refresh();
538
}
539
540
async function createBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
541
  const repo = repositoryFor(arg);
542
  if (!repo) {
543
    return;
544
  }
545
  const name = await vscode.window.showInputBox({
546
    prompt: "New bookmark name (created at the working copy's base)",
547
    validateInput: (v) => (!v.trim() || v.startsWith("-") || v.includes("..") || v.includes("//") || /[\s@{]/.test(v) ? "Not a valid bookmark name" : undefined),
548
  });
549
  if (!name) {
550
    return;
551
  }
552
  try {
553
    await repo.oxy.bookmarkCreate(repo.root, name.trim());
554
    const pick = await vscode.window.showInformationMessage(`Oxygen: created bookmark ${name}.`, "Switch to it");
555
    if (pick) {
556
      await doSwitch(repo, name.trim());
557
    }
558
    await repo.refresh();
559
  } catch (e) {
560
    showError(e);
561
  }
562
}
563
564
async function pickBookmark(repo: Repository, placeHolder: string, excludeCurrent: boolean): Promise<string | undefined> {
565
  const bookmarks = await repo.oxy.bookmarks(repo.root);
566
  const items = bookmarks.filter((b) => !excludeCurrent || !b.current).map((b) => ({ label: b.name, description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}` }));
567
  if (items.length === 0) {
568
    void vscode.window.showInformationMessage("Oxygen: no other bookmarks.");
569
    return undefined;
570
  }
571
  return (await vscode.window.showQuickPick(items, { placeHolder }))?.label;
572
}
573
574
async function mergeBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
575
  const repo = repositoryFor(arg);
576
  if (!repo) {
577
    return;
578
  }
579
  try {
580
    const name = await pickBookmark(repo, `Merge into ${repo.currentBookmark?.name ?? "the current bookmark"}`, true);
581
    if (!name) {
582
      return;
583
    }
584
    const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Merging ${name}` }, () => repo.oxy.merge(repo.root, name));
585
    await repo.refresh();
586
    if (repo.status?.conflicted) {
587
      void vscode.window.showWarningMessage(`Oxygen: merged ${name}; the result has conflicts to resolve.`);
588
    } else {
589
      vscode.window.setStatusBarMessage(`$(git-merge) ${out.split("\n")[0]}`, 5000);
590
    }
591
  } catch (e) {
592
    showError(e);
593
  }
594
}
595
596
async function deleteBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
597
  const repo = repositoryFor(arg);
598
  if (!repo) {
599
    return;
600
  }
601
  try {
602
    const name = await pickBookmark(repo, "Delete which bookmark? Its changes stay.", true);
603
    if (!name) {
604
      return;
605
    }
606
    const pick = await vscode.window.showWarningMessage(`Delete bookmark ${name}?`, { modal: true }, "Delete");
607
    if (pick !== "Delete") {
608
      return;
609
    }
610
    await repo.oxy.bookmarkDelete(repo.root, name);
611
    await repo.refresh();
612
  } catch (e) {
613
    showError(e);
614
  }
615
}
616
617
// ---------------------------------------------------------------- sync
618
619
async function pickRemote(repo: Repository): Promise<string | undefined> {
620
  const remotes = await repo.oxy.remotes(repo.root);
621
  if (remotes.length === 0) {
622
    void vscode.window.showErrorMessage("Oxygen: this repository has no remotes. Add one with oxy remote add <name> <url>.");
623
    return undefined;
624
  }
625
  if (remotes.length === 1) {
626
    return remotes[0].name;
627
  }
628
  return (await vscode.window.showQuickPick(remotes.map((r) => ({ label: r.name, description: r.url })), { placeHolder: "Remote" }))?.label;
629
}
630
631
async function fetch(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
632
  const repo = repositoryFor(arg);
633
  if (!repo) {
634
    return;
635
  }
636
  try {
637
    const remote = await pickRemote(repo);
638
    if (!remote) {
639
      return;
640
    }
641
    const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Fetching from ${remote}` }, () => repo.oxy.fetch(repo.root, remote));
642
    await repo.refresh();
643
    const fresh = info.refs.filter((r) => r.new > 0);
644
    const summary = fresh.length ? fresh.map((r) => `${r.name}: ${r.new} new`).join(", ") : "nothing new";
645
    vscode.window.setStatusBarMessage(`$(cloud-download) ${remote}: ${summary}`, 5000);
646
    if (info.unverified) {
647
      void vscode.window.showWarningMessage(`Oxygen: ${remote} has no trust policy; nothing fetched from it was verified.`);
648
    }
649
  } catch (e) {
650
    showError(e);
651
  }
652
}
653
654
async function pull(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
655
  const repo = repositoryFor(arg);
656
  if (!repo) {
657
    return;
658
  }
659
  try {
660
    const remote = await pickRemote(repo);
661
    if (!remote) {
662
      return;
663
    }
664
    const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pulling from ${remote}` }, () => repo.oxy.pull(repo.root, remote));
665
    await repo.refresh();
666
    if (!info.changed) {
667
      vscode.window.setStatusBarMessage("$(check) Already up to date", 4000);
668
    } else if (info.conflicted) {
669
      void vscode.window.showWarningMessage("Oxygen: pulled; the result has conflicts to resolve.");
670
    } else if (!info.checked_out) {
671
      void vscode.window.showInformationMessage("Oxygen: pulled; the working copy was not updated because it has changes. Check out when ready.");
672
    } else {
673
      vscode.window.setStatusBarMessage(`$(cloud-download) Pulled from ${remote}`, 4000);
674
    }
675
  } catch (e) {
676
    showError(e);
677
  }
678
}
679
680
async function push(arg: Repository | vscode.SourceControl | undefined, chooseBookmark: boolean): Promise<void> {
681
  const repo = repositoryFor(arg);
682
  if (!repo) {
683
    return;
684
  }
685
  try {
686
    const remote = await pickRemote(repo);
687
    if (!remote) {
688
      return;
689
    }
690
    let bookmark: string | undefined;
691
    if (chooseBookmark) {
692
      bookmark = await pickBookmark(repo, "Push which bookmark?", false);
693
      if (!bookmark) {
694
        return;
695
      }
696
    }
697
    if (vscode.workspace.getConfiguration("oxygen").get<boolean>("confirmSync", true)) {
698
      const pick = await vscode.window.showInformationMessage(`Push ${bookmark ?? repo.currentBookmark?.name ?? "the current bookmark"} to ${remote}?`, { modal: false }, "Push", "Always push without asking");
699
      if (!pick) {
700
        return;
701
      }
702
      if (pick === "Always push without asking") {
703
        await vscode.workspace.getConfiguration("oxygen").update("confirmSync", false, vscode.ConfigurationTarget.Global);
704
      }
705
    }
706
    const results = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pushing to ${remote}` }, () => repo.oxy.push(repo.root, remote, bookmark));
707
    await repo.refresh();
708
    const rejected = results.filter((r) => !r.ok);
709
    if (rejected.length === 0) {
710
      vscode.window.setStatusBarMessage(`$(cloud-upload) Pushed to ${remote}`, 4000);
711
      return;
712
    }
713
    const reason = rejected.map((r) => `${r.name}: ${r.reason}`).join("; ");
714
    const pick = await vscode.window.showErrorMessage(`Oxygen: push rejected. ${reason}`, "Pull", "Show Output");
715
    if (pick === "Pull") {
716
      await pull(repo);
717
    } else if (pick === "Show Output") {
718
      output.show();
719
    }
720
  } catch (e) {
721
    if (e instanceof OxyError && e.message.includes("pull first")) {
722
      const pick = await vscode.window.showWarningMessage(`Oxygen: ${e.message}`, "Pull");
723
      if (pick === "Pull") {
724
        await pull(repo);
725
      }
726
      return;
727
    }
728
    showError(e);
729
  }
730
}
731
732
async function sync(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
733
  const repo = repositoryFor(arg);
734
  if (!repo) {
735
    return;
736
  }
737
  await pull(repo);
738
  if (repo.status?.conflicted) {
739
    return;
740
  }
741
  if ((repo.currentBookmark?.ahead ?? 0) > 0) {
742
    await push(repo, false);
743
  }
744
}
745
746
/** The commit message from the input box, else a prompt. */
747
async function askMessage(repo: Repository, prompt: string): Promise<string | undefined> {
748
  const fromBox = repo.sourceControl.inputBox.value.trim();
749
  if (fromBox) {
750
    return fromBox;
751
  }
752
  return (await vscode.window.showInputBox({ prompt, placeHolder: "Commit message" }))?.trim() || undefined;
753
}
754
755
async function showLog(): Promise<void> {
756
  const repo = repositoryFor(undefined);
757
  if (!repo) {
758
    return;
759
  }
760
  try {
761
    const log = await repo.oxy.log(repo.root, 50);
762
    const items = log.changes
763
      .filter((c) => !c.working_copy)
764
      .map((c) => ({
765
        label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
766
        description: `${c.change_id.slice(0, 8)} · ${c.author}`,
767
        detail: `${new Date(c.when_ms).toLocaleString()} · ${c.signature?.status ?? "unsigned"}${c.signature?.revoked ? " (key revoked)" : ""}`,
768
        change: c,
769
      }));
770
    const pick = await vscode.window.showQuickPick(items, { placeHolder: `${log.bookmark}: ${log.total - 1} changes`, matchOnDescription: true, matchOnDetail: true });
771
    if (pick) {
772
      const doc = await vscode.workspace.openTextDocument({ content: pick.change.message, language: "markdown" });
773
      await vscode.window.showTextDocument(doc, { preview: true });
774
    }
775
  } catch (e) {
776
    showError(e);
777
  }
778
}
779
780
function showError(e: unknown): void {
781
  const message = e instanceof OxyError ? e.message : String(e);
782
  void vscode.window.showErrorMessage(`Oxygen: ${message}`, "Show Output").then((pick) => pick && output.show());
783
}