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