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