OXYGENHUB
# f6bda77ecb9f oxygen-vscode / src / extension.ts
46.7 KiB · last changed 0.7.0: exported API with repository handles, change events and remote providers; Open on Remote commands 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 { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, pickProvider, repositoryFor as rootFor } from "./api";
6
import { basename } from "node:path";
7
import { Oxy, OxyError, findBinary } from "./oxy";
8
import { Repository, OxyResourceState, SubrepoState } from "./repository";
9
import { BisectProvider, TrustProvider } from "./panels";
10
import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider";
11
import { OxyDecorationProvider } from "./decorations";
12
import { OperationItem, OperationsProvider, timeAgo } from "./operations";
13
import { BlameController } from "./blame";
14
import { HistoryPanel } from "./history";
15
import { ConflictCodeLensProvider, acceptSides, keepCandidate, markResolved } from "./conflicts";
16
17
const MIN_VERSION = { format: 2, protocol: 1 };
18
19
let output: vscode.OutputChannel;
20
let oxy: Oxy;
21
let contentProvider: OxyContentProvider;
22
let decorations: OxyDecorationProvider;
23
let operations: OperationsProvider;
24
let blame: BlameController;
25
let conflictLenses: ConflictCodeLensProvider;
26
let bisectView: BisectProvider;
27
let trustView: TrustProvider;
28
const repositories = new Map<string, Repository>();
29
30
export async function activate(context: vscode.ExtensionContext): Promise<OxygenAPI | undefined> {
31
  output = vscode.window.createOutputChannel("Oxygen");
32
  context.subscriptions.push(output);
33
  output.appendLine(`Oxygen extension ${context.extension.packageJSON.version} activating; folders: ${(vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath).join(", ") || "(none)"}`);
34
  oxy = new Oxy(findBinary(vscode.workspace.getConfiguration("oxygen").get<string>("path")), (line) => output.appendLine(line));
35
36
  try {
37
    const v = await oxy.version();
38
    output.appendLine(`oxy ${v.version} (format ${v.format}, protocol ${v.protocol}) at ${oxy.binary}`);
39
    if (v.format < MIN_VERSION.format || v.protocol < MIN_VERSION.protocol) {
40
      void vscode.window.showWarningMessage(`Oxygen: oxy ${v.version} is older than this extension supports.`);
41
    }
42
  } catch (e) {
43
    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");
44
    if (pick === "Open Settings") {
45
      void vscode.commands.executeCommand("workbench.action.openSettings", "oxygen.path");
46
    }
47
    return;
48
  }
49
50
  contentProvider = new OxyContentProvider(oxy);
51
  context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider));
52
  decorations = new OxyDecorationProvider(() => [...repositories.values()]);
53
  context.subscriptions.push(decorations);
54
  operations = new OperationsProvider(() => [...repositories.values()]);
55
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations));
56
  blame = new BlameController(() => [...repositories.values()]);
57
  context.subscriptions.push(blame);
58
  conflictLenses = new ConflictCodeLensProvider(() => [...repositories.values()]);
59
  context.subscriptions.push(vscode.languages.registerCodeLensProvider({ scheme: "file" }, conflictLenses));
60
  bisectView = new BisectProvider(() => [...repositories.values()]);
61
  trustView = new TrustProvider(() => [...repositories.values()]);
62
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.bisect", bisectView), vscode.window.registerTreeDataProvider("oxygen.trust", trustView));
63
64
  context.subscriptions.push(
65
    vscode.commands.registerCommand("oxygen.refresh", async () => {
66
      await Promise.all([...repositories.values()].map((r) => r.refresh()));
67
    }),
68
    vscode.commands.registerCommand("oxygen.showOutput", () => output.show()),
69
    vscode.commands.registerCommand("oxygen.openOnRemote", (arg?: Repository | vscode.SourceControl) => openOnRemote(undefined, arg instanceof Repository ? arg : undefined)),
70
    vscode.commands.registerCommand("oxygen.openChangeOnRemote", (root: string, hash: string) => openOnRemote({ kind: "change", hash }, repositories.get(root))),
71
    vscode.commands.registerCommand("oxygen.openFile", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
72
    vscode.commands.registerCommand("oxygen.openChanges", (state?: OxyResourceState) => state && openChanges(state)),
73
    vscode.commands.registerCommand("oxygen.openBaseVersion", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", baseUri(state.repository.root, state.path))),
74
    vscode.commands.registerCommand("oxygen.commit", (arg?: Repository | vscode.SourceControl) => commit(arg)),
75
    vscode.commands.registerCommand("oxygen.showLog", () => showLog()),
76
    vscode.commands.registerCommand("oxygen.init", () => initRepository()),
77
    vscode.commands.registerCommand("oxygen.discard", (state?: OxyResourceState, states?: OxyResourceState[]) => discard(states?.length ? states : state ? [state] : [])),
78
    vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])),
79
    vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)),
80
    vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)),
81
    vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
82
    vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
83
    vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
84
    vscode.commands.registerCommand("oxygen.switchBookmark", (arg?: Repository | vscode.SourceControl) => switchBookmark(arg)),
85
    vscode.commands.registerCommand("oxygen.createBookmark", (arg?: Repository | vscode.SourceControl) => createBookmark(arg)),
86
    vscode.commands.registerCommand("oxygen.mergeBookmark", (arg?: Repository | vscode.SourceControl) => mergeBookmark(arg)),
87
    vscode.commands.registerCommand("oxygen.deleteBookmark", (arg?: Repository | vscode.SourceControl) => deleteBookmark(arg)),
88
    vscode.commands.registerCommand("oxygen.fetch", (arg?: Repository | vscode.SourceControl) => fetch(arg)),
89
    vscode.commands.registerCommand("oxygen.pull", (arg?: Repository | vscode.SourceControl) => pull(arg)),
90
    vscode.commands.registerCommand("oxygen.push", (arg?: Repository | vscode.SourceControl) => push(arg, false)),
91
    vscode.commands.registerCommand("oxygen.pushBookmark", (arg?: Repository | vscode.SourceControl) => push(arg, true)),
92
    vscode.commands.registerCommand("oxygen.sync", (arg?: Repository | vscode.SourceControl) => sync(arg)),
93
    vscode.commands.registerCommand("oxygen.showHistory", (arg?: Repository | vscode.SourceControl) => {
94
      const repo = repositoryFor(arg);
95
      if (repo) {
96
        HistoryPanel.show(repo, (hash) => void showChange(repo.root, hash));
97
      }
98
    }),
99
    vscode.commands.registerCommand("oxygen.fileHistory", (uri?: vscode.Uri) => fileHistory(uri ?? vscode.window.activeTextEditor?.document.uri)),
100
    vscode.commands.registerCommand("oxygen.toggleBlame", () => blame.toggle()),
101
    vscode.commands.registerCommand("oxygen.showChange", (root?: string, hash?: string) => root && hash && showChange(root, hash)),
102
    vscode.commands.registerCommand("oxygen.acceptSides", (uri: vscode.Uri, index: number, keep: number[]) => acceptSides(uri, index, keep).catch(showError)),
103
    vscode.commands.registerCommand("oxygen.keepCandidate", (state?: OxyResourceState) => state && keepCandidate(state.repository, state.path).catch(showError)),
104
    vscode.commands.registerCommand("oxygen.markResolved", (arg?: Repository | vscode.SourceControl) => {
105
      const repo = repositoryFor(arg);
106
      return repo && markResolved(repo).catch(showError);
107
    }),
108
    vscode.commands.registerCommand("oxygen.openConflict", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
109
    vscode.commands.registerCommand("oxygen.openSubrepo", (state?: SubrepoState) => state && openSubrepo(state)),
110
    vscode.commands.registerCommand("oxygen.updateSubrepo", (state?: SubrepoState) => state && updateSubrepos(state.repository, true, [state.info.path])),
111
    vscode.commands.registerCommand("oxygen.updateSubrepos", (arg?: Repository | vscode.SourceControl) => {
112
      const repo = repositoryFor(arg);
113
      return repo && updateSubrepos(repo, true, []);
114
    }),
115
    vscode.commands.registerCommand("oxygen.bisectStart", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectStart(r.root).then(() => "Started; mark a good and a bad change"))),
116
    vscode.commands.registerCommand("oxygen.bisectGood", (arg?: Repository, changes?: string[]) => bisectMark(arg, "good", changes)),
117
    vscode.commands.registerCommand("oxygen.bisectBad", (arg?: Repository, changes?: string[]) => bisectMark(arg, "bad", changes)),
118
    vscode.commands.registerCommand("oxygen.bisectSkip", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectMark(r.root, "skip"))),
119
    vscode.commands.registerCommand("oxygen.bisectRun", (arg?: Repository) => bisectRun(arg)),
120
    vscode.commands.registerCommand("oxygen.bisectReset", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectReset(r.root).then(() => "Bisect reset; working copy restored"))),
121
    vscode.commands.registerCommand("oxygen.refreshBisect", () => bisectView.refresh()),
122
    vscode.commands.registerCommand("oxygen.refreshTrust", () => trustView.refresh()),
123
    vscode.commands.registerCommand("oxygen.verify", (arg?: Repository | vscode.SourceControl) => verify(arg)),
124
    vscode.commands.registerCommand("oxygen.tags", (arg?: Repository | vscode.SourceControl) => tags(arg)),
125
    vscode.commands.registerCommand("oxygen.createTag", (arg?: Repository | vscode.SourceControl) => createTag(arg)),
126
    vscode.commands.registerCommand("oxygen.addWorkspace", (arg?: Repository | vscode.SourceControl) => addWorkspace(arg)),
127
    vscode.commands.registerCommand("oxygen.clone", () => cloneRepository()),
128
    vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
129
    vscode.workspace.onDidChangeConfiguration((e) => {
130
      if (e.affectsConfiguration("oxygen.path")) {
131
        void vscode.window.showInformationMessage("Oxygen: reload the window to use the new oxy path.");
132
      }
133
      if (e.affectsConfiguration("oxygen")) {
134
        decorations.refresh();
135
        for (const r of repositories.values()) {
136
          r.scheduleRefresh();
137
        }
138
      }
139
    }),
140
  );
141
  context.subscriptions.push({ dispose: () => repositories.forEach((r) => r.dispose()) });
142
143
  await discover();
144
  return buildApi();
145
}
146
147
148
// ---------------------------------------------------------------- exported API
149
150
const remoteProviders: RemoteProvider[] = [];
151
const onDidChangeRepositoriesEmitter = new vscode.EventEmitter<void>();
152
const onDidChangeStatusEmitter = new vscode.EventEmitter<RepositoryHandle>();
153
154
function handleOf(repo: Repository): RepositoryHandle {
155
  return {
156
    root: repo.root,
157
    get status() {
158
      return repo.status;
159
    },
160
    get bookmarks() {
161
      return repo.bookmarks.map((b) => ({ name: b.name, heads: b.heads, current: b.current }));
162
    },
163
    get currentBookmark() {
164
      return repo.currentBookmark?.name;
165
    },
166
    remotes: async () => (await repo.oxy.remotes(repo.root)).map((r) => ({ name: r.name, url: r.url })),
167
    json: <T>(args: string[]) => repo.oxy.json<T>(repo.root, args),
168
    refresh: () => repo.refresh(),
169
  };
170
}
171
172
function buildApi(): OxygenAPI {
173
  return {
174
    version: 1,
175
    get repositories() {
176
      return [...repositories.values()].map(handleOf);
177
    },
178
    onDidChangeStatus: (listener) => onDidChangeStatusEmitter.event(listener),
179
    onDidChangeRepositories: (listener) => onDidChangeRepositoriesEmitter.event(listener),
180
    getRepository: (fsPath) => {
181
      const root = rootFor([...repositories.keys()], fsPath);
182
      const repo = root ? repositories.get(root) : undefined;
183
      return repo ? handleOf(repo) : undefined;
184
    },
185
    registerRemoteProvider: (provider) => {
186
      remoteProviders.push(provider);
187
      void vscode.commands.executeCommand("setContext", "oxygen.hasRemoteProvider", true);
188
      return {
189
        dispose: () => {
190
          const i = remoteProviders.indexOf(provider);
191
          if (i >= 0) {
192
            remoteProviders.splice(i, 1);
193
          }
194
          void vscode.commands.executeCommand("setContext", "oxygen.hasRemoteProvider", remoteProviders.length > 0);
195
        },
196
      };
197
    },
198
    remoteProviders: () => [...remoteProviders],
199
  };
200
}
201
202
/** Open the current repository, file or change on whichever provider knows its remote. */
203
async function openOnRemote(target?: RemoteTarget, repoArg?: Repository): Promise<void> {
204
  const editor = vscode.window.activeTextEditor;
205
  const repo = repoArg ?? (editor ? repositoryFor2(editor.document.uri) : undefined) ?? [...repositories.values()][0];
206
  if (!repo) {
207
    void vscode.window.showInformationMessage("Oxygen: no repository is open.");
208
    return;
209
  }
210
  const remotes = (await repo.oxy.remotes(repo.root)).map((r) => ({ name: r.name, url: r.url }));
211
  const pick = pickProvider(remoteProviders, remotes);
212
  if (!pick) {
213
    void vscode.window.showInformationMessage("Oxygen: no extension knows this repository's remotes. Install a forge extension such as Oxygenhub.");
214
    return;
215
  }
216
  let t: RemoteTarget = target ?? { kind: "repository" };
217
  if (!target && editor) {
218
    const rel = repo.relativePath(editor.document.uri);
219
    if (rel) {
220
      t = { kind: "file", reference: repo.currentBookmark?.name ?? "main", path: rel, line: editor.selection.active.line + 1 };
221
    }
222
  }
223
  const url = pick.provider.urlFor(pick.remote.url, t);
224
  if (url) {
225
    await vscode.env.openExternal(vscode.Uri.parse(url));
226
  }
227
}
228
229
function repositoryFor2(uri: vscode.Uri): Repository | undefined {
230
  const root = rootFor([...repositories.keys()], uri.fsPath);
231
  return root ? repositories.get(root) : undefined;
232
}
233
234
export function deactivate(): void {
235
  for (const r of repositories.values()) {
236
    r.dispose();
237
  }
238
  repositories.clear();
239
}
240
241
/** One Repository per distinct oxy root among the workspace folders. */
242
async function discover(): Promise<void> {
243
  const wanted = new Map<string, vscode.WorkspaceFolder>();
244
  for (const folder of vscode.workspace.workspaceFolders ?? []) {
245
    const info = await oxy.root(folder.uri.fsPath);
246
    if (info) {
247
      wanted.set(info.root, folder);
248
    }
249
  }
250
  for (const [root, repo] of repositories) {
251
    if (!wanted.has(root)) {
252
      repo.dispose();
253
      repositories.delete(root);
254
      onDidChangeRepositoriesEmitter.fire();
255
    }
256
  }
257
  if (wanted.size === 0 && (vscode.workspace.workspaceFolders ?? []).length > 0) {
258
    output.appendLine("no Oxygen repository in the open folders (oxy root found none)");
259
  }
260
  for (const [root, folder] of wanted) {
261
    if (!repositories.has(root)) {
262
      const repo = new Repository(oxy, root, folder);
263
      repo.onDidChangeStatus((status) => {
264
        decorations.refresh();
265
        contentProvider.invalidate(root);
266
        operations.refresh();
267
        blame.invalidate(root);
268
        HistoryPanel.refresh(root);
269
        conflictLenses.refresh();
270
        bisectView.refresh();
271
        trustView.refresh();
272
        // The commit action reads as a resolution while conflicts are resolved but uncommitted.
273
        const resolved = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true);
274
        repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: resolved ? "Commit resolution" : "Commit", arguments: [repo] };
275
        repo.sourceControl.inputBox.placeholder = resolved ? "Describe the resolution (Ctrl+Enter to commit)" : "Message (Ctrl+Enter to commit)";
276
      });
277
      repositories.set(root, repo);
278
      repo.onDidChangeStatus(() => onDidChangeStatusEmitter.fire(handleOf(repo)));
279
      onDidChangeRepositoriesEmitter.fire();
280
      output.appendLine(`repository ${root}`);
281
      await repo.refresh();
282
    }
283
  }
284
  void vscode.commands.executeCommand("setContext", "oxygen.hasRepository", repositories.size > 0);
285
}
286
287
function repositoryFor(arg: Repository | vscode.SourceControl | undefined): Repository | undefined {
288
  if (arg instanceof Repository) {
289
    return arg;
290
  }
291
  if (arg && "rootUri" in arg && arg.rootUri) {
292
    return repositories.get(arg.rootUri.fsPath);
293
  }
294
  const active = vscode.window.activeTextEditor?.document.uri;
295
  for (const repo of repositories.values()) {
296
    if (active && repo.relativePath(active) !== undefined) {
297
      return repo;
298
    }
299
  }
300
  return repositories.size === 1 ? [...repositories.values()][0] : undefined;
301
}
302
303
async function openChanges(state: OxyResourceState): Promise<void> {
304
  const repo = state.repository;
305
  const title = `${basename(state.path)} (base ↔ working copy)`;
306
  if (state.status === "Added") {
307
    await vscode.commands.executeCommand("vscode.open", state.resourceUri);
308
    return;
309
  }
310
  if (state.status === "Removed") {
311
    await vscode.commands.executeCommand("vscode.open", baseUri(repo.root, state.path));
312
    return;
313
  }
314
  await vscode.commands.executeCommand("vscode.diff", baseUri(repo.root, state.path), state.resourceUri, title);
315
}
316
317
async function commit(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
318
  const repo = repositoryFor(arg);
319
  if (!repo) {
320
    void vscode.window.showErrorMessage("Oxygen: no repository for the active file.");
321
    return;
322
  }
323
  let message = repo.sourceControl.inputBox.value.trim();
324
  if (!message) {
325
    message = (await vscode.window.showInputBox({ prompt: "Commit message", placeHolder: "What did you change?" }))?.trim() ?? "";
326
  }
327
  if (!message) {
328
    return;
329
  }
330
  try {
331
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message));
332
    repo.sourceControl.inputBox.value = "";
333
    await repo.refresh();
334
  } catch (e) {
335
    showError(e);
336
  }
337
}
338
339
async function initRepository(): Promise<void> {
340
  const folders = vscode.workspace.workspaceFolders ?? [];
341
  if (folders.length === 0) {
342
    void vscode.window.showErrorMessage("Oxygen: open a folder first.");
343
    return;
344
  }
345
  const folder = folders.length === 1 ? folders[0] : await vscode.window.showWorkspaceFolderPick({ placeHolder: "Folder to initialize as an Oxygen repository" });
346
  if (!folder) {
347
    return;
348
  }
349
  try {
350
    await oxy.check(folder.uri.fsPath, ["init"]);
351
    await discover();
352
    void vscode.window.showInformationMessage(`Oxygen: initialized ${folder.name}.`);
353
  } catch (e) {
354
    showError(e);
355
  }
356
}
357
358
async function discard(states: OxyResourceState[]): Promise<void> {
359
  if (states.length === 0) {
360
    return;
361
  }
362
  const repo = states[0].repository;
363
  const names = states.length === 1 ? basename(states[0].path) : `${states.length} files`;
364
  const pick = await vscode.window.showWarningMessage(`Discard changes in ${names}? This cannot be undone with oxy undo.`, { modal: true }, "Discard");
365
  if (pick !== "Discard") {
366
    return;
367
  }
368
  try {
369
    await repo.oxy.discard(repo.root, states.map((s) => s.path));
370
    await repo.refresh();
371
  } catch (e) {
372
    showError(e);
373
  }
374
}
375
376
async function commitSelected(states: OxyResourceState[]): Promise<void> {
377
  if (states.length === 0) {
378
    return;
379
  }
380
  const repo = states[0].repository;
381
  const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`);
382
  if (!message) {
383
    return;
384
  }
385
  try {
386
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path)));
387
    repo.sourceControl.inputBox.value = "";
388
    await repo.refresh();
389
  } catch (e) {
390
    showError(e);
391
  }
392
}
393
394
async function commitHunks(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
395
  const repo = repositoryFor(arg);
396
  if (!repo) {
397
    return;
398
  }
399
  let hunks;
400
  try {
401
    hunks = await repo.oxy.hunks(repo.root);
402
  } catch (e) {
403
    showError(e);
404
    return;
405
  }
406
  if (hunks.length === 0) {
407
    void vscode.window.showInformationMessage("Oxygen: nothing to commit.");
408
    return;
409
  }
410
  const picks = await vscode.window.showQuickPick(
411
    hunks.map((h) => ({
412
      label: `$(diff) ${h.path}`,
413
      description: `#${h.index}`,
414
      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),
415
      hunk: h,
416
      picked: false,
417
    })),
418
    { canPickMany: true, placeHolder: "Choose the hunks to commit; anything a chosen hunk needs comes along" },
419
  );
420
  if (!picks || picks.length === 0) {
421
    return;
422
  }
423
  const message = await askMessage(repo, `Commit ${picks.length} hunk${picks.length === 1 ? "" : "s"}`);
424
  if (!message) {
425
    return;
426
  }
427
  try {
428
    await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index));
429
    repo.sourceControl.inputBox.value = "";
430
    await repo.refresh();
431
  } catch (e) {
432
    showError(e);
433
  }
434
}
435
436
async function amend(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
437
  const repo = repositoryFor(arg);
438
  if (!repo) {
439
    return;
440
  }
441
  const message = repo.sourceControl.inputBox.value.trim();
442
  try {
443
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined));
444
    repo.sourceControl.inputBox.value = "";
445
    await repo.refresh();
446
  } catch (e) {
447
    showError(e);
448
  }
449
}
450
451
async function undo(arg: Repository | vscode.SourceControl | undefined, steps: number): Promise<void> {
452
  const repo = repositoryFor(arg);
453
  if (!repo || steps <= 0) {
454
    return;
455
  }
456
  if (steps > 1) {
457
    const pick = await vscode.window.showWarningMessage(`Undo the last ${steps} operations?`, { modal: true }, "Undo");
458
    if (pick !== "Undo") {
459
      return;
460
    }
461
  }
462
  try {
463
    let last = "";
464
    for (let i = 0; i < steps; i++) {
465
      last = await repo.oxy.undo(repo.root);
466
    }
467
    await repo.refresh();
468
    vscode.window.setStatusBarMessage(`$(discard) ${last || "Undone"}`, 4000);
469
  } catch (e) {
470
    showError(e);
471
  }
472
}
473
474
// ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces, clone
475
476
async function openSubrepo(state: SubrepoState): Promise<void> {
477
  if (!state.info.actual) {
478
    const pick = await vscode.window.showInformationMessage(`${state.info.path} is not checked out yet.`, "Update --init");
479
    if (pick) {
480
      await updateSubrepos(state.repository, true, [state.info.path]);
481
    }
482
    return;
483
  }
484
  const pick = await vscode.window.showQuickPick(
485
    [
486
      { label: "$(folder-opened) Open in new window", action: "window" },
487
      { label: "$(cloud-download) Update to the pin", action: "update", description: state.info.at_pin ? "already at pin" : "moves the nested repository" },
488
    ],
489
    { placeHolder: `${state.info.path} · ${state.decorations.tooltip}` },
490
  );
491
  if (pick?.action === "window") {
492
    await vscode.commands.executeCommand("vscode.openFolder", state.resourceUri, { forceNewWindow: true });
493
  } else if (pick?.action === "update") {
494
    await updateSubrepos(state.repository, true, [state.info.path]);
495
  }
496
}
497
498
async function updateSubrepos(repo: Repository, init: boolean, paths: string[]): Promise<void> {
499
  try {
500
    const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Updating subrepos" }, () => repo.oxy.submoduleUpdate(repo.root, init, paths));
501
    await repo.refresh();
502
    const lines = out.trim().split("\n").filter(Boolean);
503
    if (lines.some((l) => /missing|uncommitted|✖|x /.test(l))) {
504
      void vscode.window.showWarningMessage(`Oxygen: ${lines.join("; ")}`);
505
    } else {
506
      vscode.window.setStatusBarMessage(`$(check) ${lines.join("; ") || "Subrepos up to date"}`, 5000);
507
    }
508
  } catch (e) {
509
    showError(e);
510
  }
511
}
512
513
async function bisect(arg: Repository | undefined, run: (repo: Repository) => Promise<string>): Promise<void> {
514
  const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
515
  if (!repo) {
516
    return;
517
  }
518
  try {
519
    const out = await run(repo);
520
    await repo.refresh();
521
    vscode.window.setStatusBarMessage(`$(search) ${out.split("\n")[0]}`, 5000);
522
  } catch (e) {
523
    showError(e);
524
  }
525
}
526
527
async function bisectMark(arg: Repository | undefined, verdict: "good" | "bad", changes?: string[]): Promise<void> {
528
  const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
529
  if (!repo) {
530
    return;
531
  }
532
  let picked = changes;
533
  if (picked === undefined) {
534
    const status = await repo.oxy.bisectStatus(repo.root).catch(() => null);
535
    if (!status || status.state === "need-marks") {
536
      const value = await vscode.window.showInputBox({ prompt: `A change, bookmark or tag known to be ${verdict}`, placeHolder: verdict === "good" ? "v1.2 or a ChangeId prefix" : "main" });
537
      if (!value?.trim()) {
538
        return;
539
      }
540
      picked = value.trim().split(/\s+/);
541
    } else {
542
      picked = [];
543
    }
544
  }
545
  await bisect(repo, (r) => r.oxy.bisectMark(r.root, verdict, picked));
546
}
547
548
async function bisectRun(arg: Repository | undefined): Promise<void> {
549
  const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
550
  if (!repo) {
551
    return;
552
  }
553
  const value = await vscode.window.showInputBox({ prompt: "Command to run for each step (exit 0 good, 125 skip, anything else bad)", placeHolder: "make test" });
554
  if (!value?.trim()) {
555
    return;
556
  }
557
  await bisect(repo, (r) => Promise.resolve(vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Oxygen: bisecting", cancellable: false }, () => r.oxy.bisectRun(r.root, ["sh", "-c", value.trim()]))));
558
}
559
560
async function verify(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
561
  const repo = repositoryFor(arg);
562
  if (!repo) {
563
    return;
564
  }
565
  try {
566
    const results = await repo.oxy.verify(repo.root);
567
    const icon = (s: string, revoked: boolean) => (s === "invalid" ? "$(error)" : s === "unsigned" ? "$(circle-outline)" : revoked ? "$(warning)" : "$(verified)");
568
    const pick = await vscode.window.showQuickPick(
569
      results.map((r) => ({
570
        label: `${icon(r.status, r.revoked)} ${r.change_id.slice(0, 8)} ${r.status}${r.revoked ? " (key revoked)" : ""}`,
571
        description: r.name ?? "",
572
        detail: r.reason ?? undefined,
573
        hash: r.hash,
574
      })),
575
      { placeHolder: `${results.length} changes; ${results.filter((r) => r.status === "invalid").length} invalid`, matchOnDescription: true },
576
    );
577
    if (pick) {
578
      await showChange(repo.root, pick.hash);
579
    }
580
  } catch (e) {
581
    showError(e);
582
  }
583
}
584
585
async function tags(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
586
  const repo = repositoryFor(arg);
587
  if (!repo) {
588
    return;
589
  }
590
  try {
591
    const list = await repo.oxy.tags(repo.root);
592
    if (list.length === 0) {
593
      const pick = await vscode.window.showInformationMessage("Oxygen: no tags yet.", "Create tag");
594
      if (pick) {
595
        await createTag(repo);
596
      }
597
      return;
598
    }
599
    const pick = await vscode.window.showQuickPick(
600
      list.map((t) => ({ label: `$(tag) ${t.name}`, description: `${t.heads.length} head${t.heads.length === 1 ? "" : "s"}${t.signer ? ` · signed by ${t.signer}` : ""}`, detail: t.message || undefined, tag: t })),
601
      { placeHolder: "Check out a tag's state" },
602
    );
603
    if (!pick) {
604
      return;
605
    }
606
    await repo.oxy.checkout(repo.root, [pick.tag.name]);
607
    await repo.refresh();
608
  } catch (e) {
609
    if (e instanceof OxyError && e.message.includes("has changes")) {
610
      void vscode.window.showWarningMessage("Oxygen: commit or discard your changes before checking out a tag.");
611
      return;
612
    }
613
    showError(e);
614
  }
615
}
616
617
async function createTag(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
618
  const repo = repositoryFor(arg);
619
  if (!repo) {
620
    return;
621
  }
622
  const name = await vscode.window.showInputBox({ prompt: "Tag name (tags the current bookmark's heads)" });
623
  if (!name?.trim()) {
624
    return;
625
  }
626
  const message = (await vscode.window.showInputBox({ prompt: "Tag message (optional)" })) ?? "";
627
  try {
628
    await repo.oxy.tagCreate(repo.root, name.trim(), [], message);
629
    await repo.refresh();
630
    vscode.window.setStatusBarMessage(`$(tag) Tagged ${name.trim()}`, 4000);
631
  } catch (e) {
632
    showError(e);
633
  }
634
}
635
636
async function addWorkspace(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
637
  const repo = repositoryFor(arg);
638
  if (!repo) {
639
    return;
640
  }
641
  const folders = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Create workspace here", title: "Folder for the new workspace (must be empty or absent)" });
642
  if (!folders?.[0]) {
643
    return;
644
  }
645
  try {
646
    await repo.oxy.workspaceAdd(repo.root, folders[0].fsPath);
647
    const pick = await vscode.window.showInformationMessage(`Oxygen: workspace created at ${folders[0].fsPath}.`, "Open in new window");
648
    if (pick) {
649
      await vscode.commands.executeCommand("vscode.openFolder", folders[0], { forceNewWindow: true });
650
    }
651
  } catch (e) {
652
    showError(e);
653
  }
654
}
655
656
async function cloneRepository(): Promise<void> {
657
  const url = await vscode.window.showInputBox({ prompt: "Repository to clone (path or https URL)", placeHolder: "https://host:8890/project" });
658
  if (!url?.trim()) {
659
    return;
660
  }
661
  const parent = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Clone into this folder" });
662
  if (!parent?.[0]) {
663
    return;
664
  }
665
  const name = url.trim().replace(/\/+$/, "").split("/").pop()?.replace(/\.oxy$/, "") || "repository";
666
  const dest = vscode.Uri.joinPath(parent[0], name);
667
  try {
668
    await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: cloning ${name}` }, () => oxy.clone(parent[0].fsPath, url.trim(), dest.fsPath));
669
    const pick = await vscode.window.showInformationMessage(`Oxygen: cloned into ${dest.fsPath}.`, "Open", "Open in new window");
670
    if (pick) {
671
      await vscode.commands.executeCommand("vscode.openFolder", dest, { forceNewWindow: pick === "Open in new window" });
672
    }
673
  } catch (e) {
674
    showError(e);
675
  }
676
}
677
678
// ---------------------------------------------------------------- history
679
680
async function showChange(root: string, hash: string): Promise<void> {
681
  const repo = repositories.get(root);
682
  if (!repo) {
683
    return;
684
  }
685
  let info;
686
  try {
687
    info = await repo.oxy.show(repo.root, `#${hash.slice(0, 12)}`);
688
  } catch (e) {
689
    showError(e);
690
    return;
691
  }
692
  const subject = info.message.split("\n")[0];
693
  type Item = vscode.QuickPickItem & { action: string; path?: string };
694
  const items: Item[] = [
695
    { label: "$(diff) Show diff", description: `${info.files.length} file${info.files.length === 1 ? "" : "s"}`, action: "diff" },
696
    { label: "$(arrow-right) Check out this state", description: "the change and its dependencies", action: "checkout" },
697
    { label: "$(edit) Describe…", description: "change the message", action: "describe" },
698
    { label: "$(tag) Tag…", action: "tag" },
699
    { label: "$(trash) Abandon", description: "drop the change; its content stays on disk if the working copy sat on it", action: "abandon" },
700
    { label: "$(copy) Copy ChangeId", action: "copy" },
701
    ...info.files.map((f) => ({ label: `$(file) ${f.path}`, description: `${f.kind}${f.from ? ` from ${f.from}` : ""}`, action: "file", path: f.path })),
702
  ];
703
  const pick = await vscode.window.showQuickPick(items, {
704
    title: `${info.change_id.slice(0, 8)}  ${subject}`,
705
    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"}`,
706
    matchOnDescription: true,
707
  });
708
  if (!pick) {
709
    return;
710
  }
711
  try {
712
    switch (pick.action) {
713
      case "diff":
714
      case "file": {
715
        const text = await repo.oxy.diffText(repo.root, `#${hash.slice(0, 12)}`);
716
        const body = `# ${info.change_id.slice(0, 8)} ${subject}\n# ${info.author} · ${new Date(info.when_ms).toLocaleString()}\n\n${text}`;
717
        const doc = await vscode.workspace.openTextDocument({ content: body, language: "diff" });
718
        await vscode.window.showTextDocument(doc, { preview: true });
719
        return;
720
      }
721
      case "checkout":
722
        await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`]);
723
        break;
724
      case "describe": {
725
        const message = await vscode.window.showInputBox({ prompt: "New message", value: info.message });
726
        if (!message?.trim()) {
727
          return;
728
        }
729
        await repo.oxy.describe(repo.root, `#${hash.slice(0, 12)}`, message.trim());
730
        break;
731
      }
732
      case "tag": {
733
        const name = await vscode.window.showInputBox({ prompt: "Tag name" });
734
        if (!name?.trim()) {
735
          return;
736
        }
737
        await repo.oxy.tagCreate(repo.root, name.trim(), [`#${hash.slice(0, 12)}`]);
738
        break;
739
      }
740
      case "abandon": {
741
        const confirm = await vscode.window.showWarningMessage(`Abandon "${subject}"?`, { modal: true }, "Abandon");
742
        if (confirm !== "Abandon") {
743
          return;
744
        }
745
        const out = await repo.oxy.abandon(repo.root, `#${hash.slice(0, 12)}`);
746
        vscode.window.setStatusBarMessage(`$(trash) ${out.split("\n")[0]}`, 5000);
747
        break;
748
      }
749
      case "copy":
750
        await vscode.env.clipboard.writeText(info.change_id);
751
        return;
752
    }
753
    await repo.refresh();
754
  } catch (e) {
755
    if (e instanceof OxyError && e.message.includes("has changes")) {
756
      const confirm = await vscode.window.showWarningMessage("The working copy has uncommitted changes. Discard them and check out?", { modal: true }, "Discard and check out");
757
      if (confirm) {
758
        try {
759
          await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`], true);
760
          await repo.refresh();
761
        } catch (e2) {
762
          showError(e2);
763
        }
764
      }
765
      return;
766
    }
767
    showError(e);
768
  }
769
}
770
771
async function fileHistory(uri: vscode.Uri | undefined): Promise<void> {
772
  if (!uri) {
773
    return;
774
  }
775
  const repo = [...repositories.values()].find((r) => r.relativePath(uri) !== undefined);
776
  const rel = repo?.relativePath(uri);
777
  if (!repo || !rel) {
778
    void vscode.window.showInformationMessage("Oxygen: this file is not in an Oxygen repository.");
779
    return;
780
  }
781
  try {
782
    const log = await repo.oxy.log(repo.root, 200, rel);
783
    const changes = log.changes.filter((c) => !c.working_copy);
784
    if (changes.length === 0) {
785
      void vscode.window.showInformationMessage(`Oxygen: no committed change touches ${rel}.`);
786
      return;
787
    }
788
    const pick = await vscode.window.showQuickPick(
789
      changes.map((c, i) => ({
790
        label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
791
        description: `${c.change_id.slice(0, 8)} · ${c.author} · ${timeAgo(c.when_ms)}`,
792
        change: c,
793
        previous: changes[i + 1],
794
      })),
795
      { placeHolder: `${changes.length} change${changes.length === 1 ? "" : "s"} touched ${rel}; pick one to compare with the version before it`, matchOnDescription: true },
796
    );
797
    if (!pick) {
798
      return;
799
    }
800
    const after = oxyUriFor(repo.root, rel, pick.change.hash);
801
    if (pick.previous) {
802
      const before = oxyUriFor(repo.root, rel, pick.previous.hash);
803
      await vscode.commands.executeCommand("vscode.diff", before, after, `${basename(rel)} (${pick.previous.change_id.slice(0, 8)} ↔ ${pick.change.change_id.slice(0, 8)})`);
804
    } else {
805
      await vscode.commands.executeCommand("vscode.open", after);
806
    }
807
  } catch (e) {
808
    showError(e);
809
  }
810
}
811
812
function oxyUriFor(root: string, rel: string, hash: string): vscode.Uri {
813
  return oxyUri(root, rel, `#${hash.slice(0, 12)}`);
814
}
815
816
// ---------------------------------------------------------------- bookmarks
817
818
async function switchBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
819
  const repo = repositoryFor(arg);
820
  if (!repo) {
821
    return;
822
  }
823
  let bookmarks;
824
  try {
825
    bookmarks = await repo.oxy.bookmarks(repo.root);
826
  } catch (e) {
827
    showError(e);
828
    return;
829
  }
830
  type Item = vscode.QuickPickItem & { action: "switch" | "create" | "merge" | "delete"; name?: string };
831
  const items: Item[] = bookmarks.map((b) => ({
832
    label: `${b.current ? "$(circle-filled)" : "$(bookmark)"} ${b.name}`,
833
    description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}${b.ahead ? ` $(arrow-up)${b.ahead}` : ""}${b.behind ? ` $(arrow-down)${b.behind}` : ""}`,
834
    detail: b.current ? "current bookmark" : undefined,
835
    action: "switch",
836
    name: b.name,
837
  }));
838
  items.push(
839
    { label: "$(add) Create bookmark…", action: "create", alwaysShow: true },
840
    { label: "$(git-merge) Merge a bookmark into the current one…", action: "merge", alwaysShow: true },
841
    { label: "$(trash) Delete a bookmark…", action: "delete", alwaysShow: true },
842
  );
843
  const pick = await vscode.window.showQuickPick(items, { placeHolder: "Switch to a bookmark" });
844
  if (!pick) {
845
    return;
846
  }
847
  switch (pick.action) {
848
    case "create":
849
      return createBookmark(repo);
850
    case "merge":
851
      return mergeBookmark(repo);
852
    case "delete":
853
      return deleteBookmark(repo);
854
    case "switch":
855
      if (pick.name && !bookmarks.find((b) => b.name === pick.name)?.current) {
856
        await doSwitch(repo, pick.name);
857
      }
858
  }
859
}
860
861
async function doSwitch(repo: Repository, name: string): Promise<void> {
862
  try {
863
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${name}` }, () => repo.oxy.bookmarkSwitch(repo.root, name));
864
  } catch (e) {
865
    if (e instanceof OxyError && e.message.includes("has changes")) {
866
      const pick = await vscode.window.showWarningMessage(`The working copy has uncommitted changes. Switching to ${name} would discard them.`, { modal: true }, "Discard and switch");
867
      if (pick === "Discard and switch") {
868
        try {
869
          await repo.oxy.bookmarkSwitch(repo.root, name, true);
870
        } catch (e2) {
871
          showError(e2);
872
        }
873
      }
874
    } else {
875
      showError(e);
876
    }
877
  }
878
  await repo.refresh();
879
}
880
881
async function createBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
882
  const repo = repositoryFor(arg);
883
  if (!repo) {
884
    return;
885
  }
886
  const name = await vscode.window.showInputBox({
887
    prompt: "New bookmark name (created at the working copy's base)",
888
    validateInput: (v) => (!v.trim() || v.startsWith("-") || v.includes("..") || v.includes("//") || /[\s@{]/.test(v) ? "Not a valid bookmark name" : undefined),
889
  });
890
  if (!name) {
891
    return;
892
  }
893
  try {
894
    await repo.oxy.bookmarkCreate(repo.root, name.trim());
895
    const pick = await vscode.window.showInformationMessage(`Oxygen: created bookmark ${name}.`, "Switch to it");
896
    if (pick) {
897
      await doSwitch(repo, name.trim());
898
    }
899
    await repo.refresh();
900
  } catch (e) {
901
    showError(e);
902
  }
903
}
904
905
async function pickBookmark(repo: Repository, placeHolder: string, excludeCurrent: boolean): Promise<string | undefined> {
906
  const bookmarks = await repo.oxy.bookmarks(repo.root);
907
  const items = bookmarks.filter((b) => !excludeCurrent || !b.current).map((b) => ({ label: b.name, description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}` }));
908
  if (items.length === 0) {
909
    void vscode.window.showInformationMessage("Oxygen: no other bookmarks.");
910
    return undefined;
911
  }
912
  return (await vscode.window.showQuickPick(items, { placeHolder }))?.label;
913
}
914
915
async function mergeBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
916
  const repo = repositoryFor(arg);
917
  if (!repo) {
918
    return;
919
  }
920
  try {
921
    const name = await pickBookmark(repo, `Merge into ${repo.currentBookmark?.name ?? "the current bookmark"}`, true);
922
    if (!name) {
923
      return;
924
    }
925
    const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Merging ${name}` }, () => repo.oxy.merge(repo.root, name));
926
    await repo.refresh();
927
    if (repo.status?.conflicted) {
928
      void vscode.window.showWarningMessage(`Oxygen: merged ${name}; the result has conflicts to resolve.`);
929
    } else {
930
      vscode.window.setStatusBarMessage(`$(git-merge) ${out.split("\n")[0]}`, 5000);
931
    }
932
  } catch (e) {
933
    showError(e);
934
  }
935
}
936
937
async function deleteBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
938
  const repo = repositoryFor(arg);
939
  if (!repo) {
940
    return;
941
  }
942
  try {
943
    const name = await pickBookmark(repo, "Delete which bookmark? Its changes stay.", true);
944
    if (!name) {
945
      return;
946
    }
947
    const pick = await vscode.window.showWarningMessage(`Delete bookmark ${name}?`, { modal: true }, "Delete");
948
    if (pick !== "Delete") {
949
      return;
950
    }
951
    await repo.oxy.bookmarkDelete(repo.root, name);
952
    await repo.refresh();
953
  } catch (e) {
954
    showError(e);
955
  }
956
}
957
958
// ---------------------------------------------------------------- sync
959
960
async function pickRemote(repo: Repository): Promise<string | undefined> {
961
  const remotes = await repo.oxy.remotes(repo.root);
962
  if (remotes.length === 0) {
963
    void vscode.window.showErrorMessage("Oxygen: this repository has no remotes. Add one with oxy remote add <name> <url>.");
964
    return undefined;
965
  }
966
  if (remotes.length === 1) {
967
    return remotes[0].name;
968
  }
969
  return (await vscode.window.showQuickPick(remotes.map((r) => ({ label: r.name, description: r.url })), { placeHolder: "Remote" }))?.label;
970
}
971
972
async function fetch(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
973
  const repo = repositoryFor(arg);
974
  if (!repo) {
975
    return;
976
  }
977
  try {
978
    const remote = await pickRemote(repo);
979
    if (!remote) {
980
      return;
981
    }
982
    const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Fetching from ${remote}` }, () => repo.oxy.fetch(repo.root, remote));
983
    await repo.refresh();
984
    const fresh = info.refs.filter((r) => r.new > 0);
985
    const summary = fresh.length ? fresh.map((r) => `${r.name}: ${r.new} new`).join(", ") : "nothing new";
986
    vscode.window.setStatusBarMessage(`$(cloud-download) ${remote}: ${summary}`, 5000);
987
    if (info.unverified) {
988
      void vscode.window.showWarningMessage(`Oxygen: ${remote} has no trust policy; nothing fetched from it was verified.`);
989
    }
990
  } catch (e) {
991
    showError(e);
992
  }
993
}
994
995
async function pull(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
996
  const repo = repositoryFor(arg);
997
  if (!repo) {
998
    return;
999
  }
1000
  try {
1001
    const remote = await pickRemote(repo);
1002
    if (!remote) {
1003
      return;
1004
    }
1005
    const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pulling from ${remote}` }, () => repo.oxy.pull(repo.root, remote));
1006
    await repo.refresh();
1007
    if (!info.changed) {
1008
      vscode.window.setStatusBarMessage("$(check) Already up to date", 4000);
1009
    } else if (info.conflicted) {
1010
      void vscode.window.showWarningMessage("Oxygen: pulled; the result has conflicts to resolve.");
1011
    } else if (!info.checked_out) {
1012
      void vscode.window.showInformationMessage("Oxygen: pulled; the working copy was not updated because it has changes. Check out when ready.");
1013
    } else {
1014
      vscode.window.setStatusBarMessage(`$(cloud-download) Pulled from ${remote}`, 4000);
1015
    }
1016
  } catch (e) {
1017
    showError(e);
1018
  }
1019
}
1020
1021
async function push(arg: Repository | vscode.SourceControl | undefined, chooseBookmark: boolean): Promise<void> {
1022
  const repo = repositoryFor(arg);
1023
  if (!repo) {
1024
    return;
1025
  }
1026
  try {
1027
    const remote = await pickRemote(repo);
1028
    if (!remote) {
1029
      return;
1030
    }
1031
    let bookmark: string | undefined;
1032
    if (chooseBookmark) {
1033
      bookmark = await pickBookmark(repo, "Push which bookmark?", false);
1034
      if (!bookmark) {
1035
        return;
1036
      }
1037
    }
1038
    if (vscode.workspace.getConfiguration("oxygen").get<boolean>("confirmSync", true)) {
1039
      const pick = await vscode.window.showInformationMessage(`Push ${bookmark ?? repo.currentBookmark?.name ?? "the current bookmark"} to ${remote}?`, { modal: false }, "Push", "Always push without asking");
1040
      if (!pick) {
1041
        return;
1042
      }
1043
      if (pick === "Always push without asking") {
1044
        await vscode.workspace.getConfiguration("oxygen").update("confirmSync", false, vscode.ConfigurationTarget.Global);
1045
      }
1046
    }
1047
    const results = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pushing to ${remote}` }, () => repo.oxy.push(repo.root, remote, bookmark));
1048
    await repo.refresh();
1049
    const rejected = results.filter((r) => !r.ok);
1050
    if (rejected.length === 0) {
1051
      vscode.window.setStatusBarMessage(`$(cloud-upload) Pushed to ${remote}`, 4000);
1052
      return;
1053
    }
1054
    const reason = rejected.map((r) => `${r.name}: ${r.reason}`).join("; ");
1055
    const pick = await vscode.window.showErrorMessage(`Oxygen: push rejected. ${reason}`, "Pull", "Show Output");
1056
    if (pick === "Pull") {
1057
      await pull(repo);
1058
    } else if (pick === "Show Output") {
1059
      output.show();
1060
    }
1061
  } catch (e) {
1062
    if (e instanceof OxyError && e.message.includes("pull first")) {
1063
      const pick = await vscode.window.showWarningMessage(`Oxygen: ${e.message}`, "Pull");
1064
      if (pick === "Pull") {
1065
        await pull(repo);
1066
      }
1067
      return;
1068
    }
1069
    showError(e);
1070
  }
1071
}
1072
1073
async function sync(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1074
  const repo = repositoryFor(arg);
1075
  if (!repo) {
1076
    return;
1077
  }
1078
  await pull(repo);
1079
  if (repo.status?.conflicted) {
1080
    return;
1081
  }
1082
  if ((repo.currentBookmark?.ahead ?? 0) > 0) {
1083
    await push(repo, false);
1084
  }
1085
}
1086
1087
/** The commit message from the input box, else a prompt. */
1088
async function askMessage(repo: Repository, prompt: string): Promise<string | undefined> {
1089
  const fromBox = repo.sourceControl.inputBox.value.trim();
1090
  if (fromBox) {
1091
    return fromBox;
1092
  }
1093
  return (await vscode.window.showInputBox({ prompt, placeHolder: "Commit message" }))?.trim() || undefined;
1094
}
1095
1096
async function showLog(): Promise<void> {
1097
  const repo = repositoryFor(undefined);
1098
  if (!repo) {
1099
    return;
1100
  }
1101
  try {
1102
    const log = await repo.oxy.log(repo.root, 50);
1103
    const items = log.changes
1104
      .filter((c) => !c.working_copy)
1105
      .map((c) => ({
1106
        label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
1107
        description: `${c.change_id.slice(0, 8)} · ${c.author}`,
1108
        detail: `${new Date(c.when_ms).toLocaleString()} · ${c.signature?.status ?? "unsigned"}${c.signature?.revoked ? " (key revoked)" : ""}`,
1109
        change: c,
1110
      }));
1111
    const pick = await vscode.window.showQuickPick(items, { placeHolder: `${log.bookmark}: ${log.total - 1} changes`, matchOnDescription: true, matchOnDetail: true });
1112
    if (pick) {
1113
      const doc = await vscode.workspace.openTextDocument({ content: pick.change.message, language: "markdown" });
1114
      await vscode.window.showTextDocument(doc, { preview: true });
1115
    }
1116
  } catch (e) {
1117
    showError(e);
1118
  }
1119
}
1120
1121
function showError(e: unknown): void {
1122
  const message = e instanceof OxyError ? e.message : String(e);
1123
  void vscode.window.showErrorMessage(`Oxygen: ${message}`, "Show Output").then((pick) => pick && output.show());
1124
}