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