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