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