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