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";
5
import { basename } from "node:path";
6
import { Oxy, OxyError, findBinary } from "./oxy";
7
import { Repository, OxyResourceState } from "./repository";
ab81294a Andy McCall 1 month ago ↶
8
import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider";
bed495a9 Andy McCall 1 month ago ↶
9
import { OxyDecorationProvider } from "./decorations";
ab81294a Andy McCall 1 month ago ↶
10
import { OperationItem, OperationsProvider, timeAgo } from "./operations";
11
import { BlameController } from "./blame";
12
import { HistoryPanel } from "./history";
bed495a9 Andy McCall 1 month ago ↶
13
14
const MIN_VERSION = { format: 2, protocol: 1 };
15
16
let output: vscode.OutputChannel;
17
let oxy: Oxy;
18
let contentProvider: OxyContentProvider;
19
let decorations: OxyDecorationProvider;
35d56b58 Andy McCall 1 month ago ↶
20
let operations: OperationsProvider;
ab81294a Andy McCall 1 month ago ↶
21
let blame: BlameController;
bed495a9 Andy McCall 1 month ago ↶
22
const repositories = new Map<string, Repository>();
23
24
export async function activate(context: vscode.ExtensionContext): Promise<void> {
25
output = vscode.window.createOutputChannel("Oxygen");
26
context.subscriptions.push(output);
810a1c53 Andy McCall 1 month ago ↶
27
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 ↶
28
oxy = new Oxy(findBinary(vscode.workspace.getConfiguration("oxygen").get<string>("path")), (line) => output.appendLine(line));
29
30
try {
31
const v = await oxy.version();
32
output.appendLine(`oxy ${v.version} (format ${v.format}, protocol ${v.protocol}) at ${oxy.binary}`);
33
if (v.format < MIN_VERSION.format || v.protocol < MIN_VERSION.protocol) {
34
void vscode.window.showWarningMessage(`Oxygen: oxy ${v.version} is older than this extension supports.`);
35
}
36
} catch (e) {
37
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");
38
if (pick === "Open Settings") {
39
void vscode.commands.executeCommand("workbench.action.openSettings", "oxygen.path");
40
}
41
return;
42
}
43
44
contentProvider = new OxyContentProvider(oxy);
45
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider));
46
decorations = new OxyDecorationProvider(() => [...repositories.values()]);
47
context.subscriptions.push(decorations);
35d56b58 Andy McCall 1 month ago ↶
48
operations = new OperationsProvider(() => [...repositories.values()]);
49
context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations));
ab81294a Andy McCall 1 month ago ↶
50
blame = new BlameController(() => [...repositories.values()]);
51
context.subscriptions.push(blame);
bed495a9 Andy McCall 1 month ago ↶
52
53
context.subscriptions.push(
54
vscode.commands.registerCommand("oxygen.refresh", async () => {
55
await Promise.all([...repositories.values()].map((r) => r.refresh()));
56
}),
57
vscode.commands.registerCommand("oxygen.showOutput", () => output.show()),
58
vscode.commands.registerCommand("oxygen.openFile", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
59
vscode.commands.registerCommand("oxygen.openChanges", (state?: OxyResourceState) => state && openChanges(state)),
60
vscode.commands.registerCommand("oxygen.openBaseVersion", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", baseUri(state.repository.root, state.path))),
61
vscode.commands.registerCommand("oxygen.commit", (arg?: Repository | vscode.SourceControl) => commit(arg)),
62
vscode.commands.registerCommand("oxygen.showLog", () => showLog()),
810a1c53 Andy McCall 1 month ago ↶
63
vscode.commands.registerCommand("oxygen.init", () => initRepository()),
35d56b58 Andy McCall 1 month ago ↶
64
vscode.commands.registerCommand("oxygen.discard", (state?: OxyResourceState, states?: OxyResourceState[]) => discard(states?.length ? states : state ? [state] : [])),
65
vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])),
66
vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)),
67
vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)),
68
vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
69
vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
70
vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
c8f95589 Andy McCall 1 month ago ↶
71
vscode.commands.registerCommand("oxygen.switchBookmark", (arg?: Repository | vscode.SourceControl) => switchBookmark(arg)),
72
vscode.commands.registerCommand("oxygen.createBookmark", (arg?: Repository | vscode.SourceControl) => createBookmark(arg)),
73
vscode.commands.registerCommand("oxygen.mergeBookmark", (arg?: Repository | vscode.SourceControl) => mergeBookmark(arg)),
74
vscode.commands.registerCommand("oxygen.deleteBookmark", (arg?: Repository | vscode.SourceControl) => deleteBookmark(arg)),
75
vscode.commands.registerCommand("oxygen.fetch", (arg?: Repository | vscode.SourceControl) => fetch(arg)),
76
vscode.commands.registerCommand("oxygen.pull", (arg?: Repository | vscode.SourceControl) => pull(arg)),
77
vscode.commands.registerCommand("oxygen.push", (arg?: Repository | vscode.SourceControl) => push(arg, false)),
78
vscode.commands.registerCommand("oxygen.pushBookmark", (arg?: Repository | vscode.SourceControl) => push(arg, true)),
79
vscode.commands.registerCommand("oxygen.sync", (arg?: Repository | vscode.SourceControl) => sync(arg)),
ab81294a Andy McCall 1 month ago ↶
80
vscode.commands.registerCommand("oxygen.showHistory", (arg?: Repository | vscode.SourceControl) => {
81
const repo = repositoryFor(arg);
82
if (repo) {
83
HistoryPanel.show(repo, (hash) => void showChange(repo.root, hash));
84
}
85
}),
86
vscode.commands.registerCommand("oxygen.fileHistory", (uri?: vscode.Uri) => fileHistory(uri ?? vscode.window.activeTextEditor?.document.uri)),
87
vscode.commands.registerCommand("oxygen.toggleBlame", () => blame.toggle()),
88
vscode.commands.registerCommand("oxygen.showChange", (root?: string, hash?: string) => root && hash && showChange(root, hash)),
bed495a9 Andy McCall 1 month ago ↶
89
vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
90
vscode.workspace.onDidChangeConfiguration((e) => {
91
if (e.affectsConfiguration("oxygen.path")) {
92
void vscode.window.showInformationMessage("Oxygen: reload the window to use the new oxy path.");
93
}
94
if (e.affectsConfiguration("oxygen")) {
95
decorations.refresh();
96
for (const r of repositories.values()) {
97
r.scheduleRefresh();
98
}
99
}
100
}),
101
);
102
context.subscriptions.push({ dispose: () => repositories.forEach((r) => r.dispose()) });
103
104
await discover();
105
}
106
107
export function deactivate(): void {
108
for (const r of repositories.values()) {
109
r.dispose();
110
}
111
repositories.clear();
112
}
113
114
/** One Repository per distinct oxy root among the workspace folders. */
115
async function discover(): Promise<void> {
116
const wanted = new Map<string, vscode.WorkspaceFolder>();
117
for (const folder of vscode.workspace.workspaceFolders ?? []) {
118
const info = await oxy.root(folder.uri.fsPath);
119
if (info) {
120
wanted.set(info.root, folder);
121
}
122
}
123
for (const [root, repo] of repositories) {
124
if (!wanted.has(root)) {
125
repo.dispose();
126
repositories.delete(root);
127
}
128
}
810a1c53 Andy McCall 1 month ago ↶
129
if (wanted.size === 0 && (vscode.workspace.workspaceFolders ?? []).length > 0) {
130
output.appendLine("no Oxygen repository in the open folders (oxy root found none)");
131
}
bed495a9 Andy McCall 1 month ago ↶
132
for (const [root, folder] of wanted) {
133
if (!repositories.has(root)) {
134
const repo = new Repository(oxy, root, folder);
135
repo.onDidChangeStatus(() => {
136
decorations.refresh();
137
contentProvider.invalidate(root);
35d56b58 Andy McCall 1 month ago ↶
138
operations.refresh();
ab81294a Andy McCall 1 month ago ↶
139
blame.invalidate(root);
140
HistoryPanel.refresh(root);
bed495a9 Andy McCall 1 month ago ↶
141
});
142
repositories.set(root, repo);
143
output.appendLine(`repository ${root}`);
144
await repo.refresh();
145
}
146
}
147
void vscode.commands.executeCommand("setContext", "oxygen.hasRepository", repositories.size > 0);
148
}
149
150
function repositoryFor(arg: Repository | vscode.SourceControl | undefined): Repository | undefined {
151
if (arg instanceof Repository) {
152
return arg;
153
}
154
if (arg && "rootUri" in arg && arg.rootUri) {
155
return repositories.get(arg.rootUri.fsPath);
156
}
157
const active = vscode.window.activeTextEditor?.document.uri;
158
for (const repo of repositories.values()) {
159
if (active && repo.relativePath(active) !== undefined) {
160
return repo;
161
}
162
}
163
return repositories.size === 1 ? [...repositories.values()][0] : undefined;
164
}
165
166
async function openChanges(state: OxyResourceState): Promise<void> {
167
const repo = state.repository;
168
const title = `${basename(state.path)} (base ↔ working copy)`;
169
if (state.status === "Added") {
170
await vscode.commands.executeCommand("vscode.open", state.resourceUri);
171
return;
172
}
173
if (state.status === "Removed") {
174
await vscode.commands.executeCommand("vscode.open", baseUri(repo.root, state.path));
175
return;
176
}
177
await vscode.commands.executeCommand("vscode.diff", baseUri(repo.root, state.path), state.resourceUri, title);
178
}
179
180
async function commit(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
181
const repo = repositoryFor(arg);
182
if (!repo) {
183
void vscode.window.showErrorMessage("Oxygen: no repository for the active file.");
184
return;
185
}
186
let message = repo.sourceControl.inputBox.value.trim();
187
if (!message) {
188
message = (await vscode.window.showInputBox({ prompt: "Commit message", placeHolder: "What did you change?" }))?.trim() ?? "";
189
}
190
if (!message) {
191
return;
192
}
193
try {
194
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message));
195
repo.sourceControl.inputBox.value = "";
196
await repo.refresh();
197
} catch (e) {
198
showError(e);
199
}
200
}
201
810a1c53 Andy McCall 1 month ago ↶
202
async function initRepository(): Promise<void> {
203
const folders = vscode.workspace.workspaceFolders ?? [];
204
if (folders.length === 0) {
205
void vscode.window.showErrorMessage("Oxygen: open a folder first.");
206
return;
207
}
208
const folder = folders.length === 1 ? folders[0] : await vscode.window.showWorkspaceFolderPick({ placeHolder: "Folder to initialize as an Oxygen repository" });
209
if (!folder) {
210
return;
211
}
212
try {
213
await oxy.check(folder.uri.fsPath, ["init"]);
214
await discover();
215
void vscode.window.showInformationMessage(`Oxygen: initialized ${folder.name}.`);
216
} catch (e) {
217
showError(e);
218
}
219
}
220
35d56b58 Andy McCall 1 month ago ↶
221
async function discard(states: OxyResourceState[]): Promise<void> {
222
if (states.length === 0) {
223
return;
224
}
225
const repo = states[0].repository;
226
const names = states.length === 1 ? basename(states[0].path) : `${states.length} files`;
227
const pick = await vscode.window.showWarningMessage(`Discard changes in ${names}? This cannot be undone with oxy undo.`, { modal: true }, "Discard");
228
if (pick !== "Discard") {
229
return;
230
}
231
try {
232
await repo.oxy.discard(repo.root, states.map((s) => s.path));
233
await repo.refresh();
234
} catch (e) {
235
showError(e);
236
}
237
}
238
239
async function commitSelected(states: OxyResourceState[]): Promise<void> {
240
if (states.length === 0) {
241
return;
242
}
243
const repo = states[0].repository;
244
const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`);
245
if (!message) {
246
return;
247
}
248
try {
249
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path)));
250
repo.sourceControl.inputBox.value = "";
251
await repo.refresh();
252
} catch (e) {
253
showError(e);
254
}
255
}
256
257
async function commitHunks(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
258
const repo = repositoryFor(arg);
259
if (!repo) {
260
return;
261
}
262
let hunks;
263
try {
264
hunks = await repo.oxy.hunks(repo.root);
265
} catch (e) {
266
showError(e);
267
return;
268
}
269
if (hunks.length === 0) {
270
void vscode.window.showInformationMessage("Oxygen: nothing to commit.");
271
return;
272
}
273
const picks = await vscode.window.showQuickPick(
274
hunks.map((h) => ({
275
label: `$(diff) ${h.path}`,
276
description: `#${h.index}`,
277
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),
278
hunk: h,
279
picked: false,
280
})),
281
{ canPickMany: true, placeHolder: "Choose the hunks to commit; anything a chosen hunk needs comes along" },
282
);
283
if (!picks || picks.length === 0) {
284
return;
285
}
286
const message = await askMessage(repo, `Commit ${picks.length} hunk${picks.length === 1 ? "" : "s"}`);
287
if (!message) {
288
return;
289
}
290
try {
291
await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index));
292
repo.sourceControl.inputBox.value = "";
293
await repo.refresh();
294
} catch (e) {
295
showError(e);
296
}
297
}
298
299
async function amend(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
300
const repo = repositoryFor(arg);
301
if (!repo) {
302
return;
303
}
304
const message = repo.sourceControl.inputBox.value.trim();
305
try {
306
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined));
307
repo.sourceControl.inputBox.value = "";
308
await repo.refresh();
309
} catch (e) {
310
showError(e);
311
}
312
}
313
314
async function undo(arg: Repository | vscode.SourceControl | undefined, steps: number): Promise<void> {
315
const repo = repositoryFor(arg);
316
if (!repo || steps <= 0) {
317
return;
318
}
319
if (steps > 1) {
320
const pick = await vscode.window.showWarningMessage(`Undo the last ${steps} operations?`, { modal: true }, "Undo");
321
if (pick !== "Undo") {
322
return;
323
}
324
}
325
try {
326
let last = "";
327
for (let i = 0; i < steps; i++) {
328
last = await repo.oxy.undo(repo.root);
329
}
330
await repo.refresh();
331
vscode.window.setStatusBarMessage(`$(discard) ${last || "Undone"}`, 4000);
332
} catch (e) {
333
showError(e);
334
}
335
}
336
ab81294a Andy McCall 1 month ago ↶
337
// ---------------------------------------------------------------- history
338
339
async function showChange(root: string, hash: string): Promise<void> {
340
const repo = repositories.get(root);
341
if (!repo) {
342
return;
343
}
344
let info;
345
try {
346
info = await repo.oxy.show(repo.root, `#${hash.slice(0, 12)}`);
347
} catch (e) {
348
showError(e);
349
return;
350
}
351
const subject = info.message.split("\n")[0];
352
type Item = vscode.QuickPickItem & { action: string; path?: string };
353
const items: Item[] = [
354
{ label: "$(diff) Show diff", description: `${info.files.length} file${info.files.length === 1 ? "" : "s"}`, action: "diff" },
355
{ label: "$(arrow-right) Check out this state", description: "the change and its dependencies", action: "checkout" },
356
{ label: "$(edit) Describe…", description: "change the message", action: "describe" },
357
{ label: "$(tag) Tag…", action: "tag" },
358
{ label: "$(trash) Abandon", description: "drop the change; its content stays on disk if the working copy sat on it", action: "abandon" },
359
{ label: "$(copy) Copy ChangeId", action: "copy" },
360
...info.files.map((f) => ({ label: `$(file) ${f.path}`, description: `${f.kind}${f.from ? ` from ${f.from}` : ""}`, action: "file", path: f.path })),
361
];
362
const pick = await vscode.window.showQuickPick(items, {
363
title: `${info.change_id.slice(0, 8)} ${subject}`,
364
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"}`,
365
matchOnDescription: true,
366
});
367
if (!pick) {
368
return;
369
}
370
try {
371
switch (pick.action) {
372
case "diff":
373
case "file": {
374
const text = await repo.oxy.diffText(repo.root, `#${hash.slice(0, 12)}`);
375
const body = `# ${info.change_id.slice(0, 8)} ${subject}\n# ${info.author} · ${new Date(info.when_ms).toLocaleString()}\n\n${text}`;
376
const doc = await vscode.workspace.openTextDocument({ content: body, language: "diff" });
377
await vscode.window.showTextDocument(doc, { preview: true });
378
return;
379
}
380
case "checkout":
381
await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`]);
382
break;
383
case "describe": {
384
const message = await vscode.window.showInputBox({ prompt: "New message", value: info.message });
385
if (!message?.trim()) {
386
return;
387
}
388
await repo.oxy.describe(repo.root, `#${hash.slice(0, 12)}`, message.trim());
389
break;
390
}
391
case "tag": {
392
const name = await vscode.window.showInputBox({ prompt: "Tag name" });
393
if (!name?.trim()) {
394
return;
395
}
396
await repo.oxy.tagCreate(repo.root, name.trim(), [`#${hash.slice(0, 12)}`]);
397
break;
398
}
399
case "abandon": {
400
const confirm = await vscode.window.showWarningMessage(`Abandon "${subject}"?`, { modal: true }, "Abandon");
401
if (confirm !== "Abandon") {
402
return;
403
}
404
const out = await repo.oxy.abandon(repo.root, `#${hash.slice(0, 12)}`);
405
vscode.window.setStatusBarMessage(`$(trash) ${out.split("\n")[0]}`, 5000);
406
break;
407
}
408
case "copy":
409
await vscode.env.clipboard.writeText(info.change_id);
410
return;
411
}
412
await repo.refresh();
413
} catch (e) {
414
if (e instanceof OxyError && e.message.includes("has changes")) {
415
const confirm = await vscode.window.showWarningMessage("The working copy has uncommitted changes. Discard them and check out?", { modal: true }, "Discard and check out");
416
if (confirm) {
417
try {
418
await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`], true);
419
await repo.refresh();
420
} catch (e2) {
421
showError(e2);
422
}
423
}
424
return;
425
}
426
showError(e);
427
}
428
}
429
430
async function fileHistory(uri: vscode.Uri | undefined): Promise<void> {
431
if (!uri) {
432
return;
433
}
434
const repo = [...repositories.values()].find((r) => r.relativePath(uri) !== undefined);
435
const rel = repo?.relativePath(uri);
436
if (!repo || !rel) {
437
void vscode.window.showInformationMessage("Oxygen: this file is not in an Oxygen repository.");
438
return;
439
}
440
try {
441
const log = await repo.oxy.log(repo.root, 200, rel);
442
const changes = log.changes.filter((c) => !c.working_copy);
443
if (changes.length === 0) {
444
void vscode.window.showInformationMessage(`Oxygen: no committed change touches ${rel}.`);
445
return;
446
}
447
const pick = await vscode.window.showQuickPick(
448
changes.map((c, i) => ({
449
label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
450
description: `${c.change_id.slice(0, 8)} · ${c.author} · ${timeAgo(c.when_ms)}`,
451
change: c,
452
previous: changes[i + 1],
453
})),
454
{ placeHolder: `${changes.length} change${changes.length === 1 ? "" : "s"} touched ${rel}; pick one to compare with the version before it`, matchOnDescription: true },
455
);
456
if (!pick) {
457
return;
458
}
459
const after = oxyUriFor(repo.root, rel, pick.change.hash);
460
if (pick.previous) {
461
const before = oxyUriFor(repo.root, rel, pick.previous.hash);
462
await vscode.commands.executeCommand("vscode.diff", before, after, `${basename(rel)} (${pick.previous.change_id.slice(0, 8)} ↔ ${pick.change.change_id.slice(0, 8)})`);
463
} else {
464
await vscode.commands.executeCommand("vscode.open", after);
465
}
466
} catch (e) {
467
showError(e);
468
}
469
}
470
471
function oxyUriFor(root: string, rel: string, hash: string): vscode.Uri {
472
return oxyUri(root, rel, `#${hash.slice(0, 12)}`);
473
}
474
c8f95589 Andy McCall 1 month ago ↶
475
// ---------------------------------------------------------------- bookmarks
476
477
async function switchBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
478
const repo = repositoryFor(arg);
479
if (!repo) {
480
return;
481
}
482
let bookmarks;
483
try {
484
bookmarks = await repo.oxy.bookmarks(repo.root);
485
} catch (e) {
486
showError(e);
487
return;
488
}
489
type Item = vscode.QuickPickItem & { action: "switch" | "create" | "merge" | "delete"; name?: string };
490
const items: Item[] = bookmarks.map((b) => ({
491
label: `${b.current ? "$(circle-filled)" : "$(bookmark)"} ${b.name}`,
492
description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}${b.ahead ? ` $(arrow-up)${b.ahead}` : ""}${b.behind ? ` $(arrow-down)${b.behind}` : ""}`,
493
detail: b.current ? "current bookmark" : undefined,
494
action: "switch",
495
name: b.name,
496
}));
497
items.push(
498
{ label: "$(add) Create bookmark…", action: "create", alwaysShow: true },
499
{ label: "$(git-merge) Merge a bookmark into the current one…", action: "merge", alwaysShow: true },
500
{ label: "$(trash) Delete a bookmark…", action: "delete", alwaysShow: true },
501
);
502
const pick = await vscode.window.showQuickPick(items, { placeHolder: "Switch to a bookmark" });
503
if (!pick) {
504
return;
505
}
506
switch (pick.action) {
507
case "create":
508
return createBookmark(repo);
509
case "merge":
510
return mergeBookmark(repo);
511
case "delete":
512
return deleteBookmark(repo);
513
case "switch":
514
if (pick.name && !bookmarks.find((b) => b.name === pick.name)?.current) {
515
await doSwitch(repo, pick.name);
516
}
517
}
518
}
519
520
async function doSwitch(repo: Repository, name: string): Promise<void> {
521
try {
522
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${name}` }, () => repo.oxy.bookmarkSwitch(repo.root, name));
523
} catch (e) {
524
if (e instanceof OxyError && e.message.includes("has changes")) {
525
const pick = await vscode.window.showWarningMessage(`The working copy has uncommitted changes. Switching to ${name} would discard them.`, { modal: true }, "Discard and switch");
526
if (pick === "Discard and switch") {
527
try {
528
await repo.oxy.bookmarkSwitch(repo.root, name, true);
529
} catch (e2) {
530
showError(e2);
531
}
532
}
533
} else {
534
showError(e);
535
}
536
}
537
await repo.refresh();
538
}
539
540
async function createBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
541
const repo = repositoryFor(arg);
542
if (!repo) {
543
return;
544
}
545
const name = await vscode.window.showInputBox({
546
prompt: "New bookmark name (created at the working copy's base)",
547
validateInput: (v) => (!v.trim() || v.startsWith("-") || v.includes("..") || v.includes("//") || /[\s@{]/.test(v) ? "Not a valid bookmark name" : undefined),
548
});
549
if (!name) {
550
return;
551
}
552
try {
553
await repo.oxy.bookmarkCreate(repo.root, name.trim());
554
const pick = await vscode.window.showInformationMessage(`Oxygen: created bookmark ${name}.`, "Switch to it");
555
if (pick) {
556
await doSwitch(repo, name.trim());
557
}
558
await repo.refresh();
559
} catch (e) {
560
showError(e);
561
}
562
}
563
564
async function pickBookmark(repo: Repository, placeHolder: string, excludeCurrent: boolean): Promise<string | undefined> {
565
const bookmarks = await repo.oxy.bookmarks(repo.root);
566
const items = bookmarks.filter((b) => !excludeCurrent || !b.current).map((b) => ({ label: b.name, description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}` }));
567
if (items.length === 0) {
568
void vscode.window.showInformationMessage("Oxygen: no other bookmarks.");
569
return undefined;
570
}
571
return (await vscode.window.showQuickPick(items, { placeHolder }))?.label;
572
}
573
574
async function mergeBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
575
const repo = repositoryFor(arg);
576
if (!repo) {
577
return;
578
}
579
try {
580
const name = await pickBookmark(repo, `Merge into ${repo.currentBookmark?.name ?? "the current bookmark"}`, true);
581
if (!name) {
582
return;
583
}
584
const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Merging ${name}` }, () => repo.oxy.merge(repo.root, name));
585
await repo.refresh();
586
if (repo.status?.conflicted) {
587
void vscode.window.showWarningMessage(`Oxygen: merged ${name}; the result has conflicts to resolve.`);
588
} else {
589
vscode.window.setStatusBarMessage(`$(git-merge) ${out.split("\n")[0]}`, 5000);
590
}
591
} catch (e) {
592
showError(e);
593
}
594
}
595
596
async function deleteBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
597
const repo = repositoryFor(arg);
598
if (!repo) {
599
return;
600
}
601
try {
602
const name = await pickBookmark(repo, "Delete which bookmark? Its changes stay.", true);
603
if (!name) {
604
return;
605
}
606
const pick = await vscode.window.showWarningMessage(`Delete bookmark ${name}?`, { modal: true }, "Delete");
607
if (pick !== "Delete") {
608
return;
609
}
610
await repo.oxy.bookmarkDelete(repo.root, name);
611
await repo.refresh();
612
} catch (e) {
613
showError(e);
614
}
615
}
616
617
// ---------------------------------------------------------------- sync
618
619
async function pickRemote(repo: Repository): Promise<string | undefined> {
620
const remotes = await repo.oxy.remotes(repo.root);
621
if (remotes.length === 0) {
622
void vscode.window.showErrorMessage("Oxygen: this repository has no remotes. Add one with oxy remote add <name> <url>.");
623
return undefined;
624
}
625
if (remotes.length === 1) {
626
return remotes[0].name;
627
}
628
return (await vscode.window.showQuickPick(remotes.map((r) => ({ label: r.name, description: r.url })), { placeHolder: "Remote" }))?.label;
629
}
630
631
async function fetch(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
632
const repo = repositoryFor(arg);
633
if (!repo) {
634
return;
635
}
636
try {
637
const remote = await pickRemote(repo);
638
if (!remote) {
639
return;
640
}
641
const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Fetching from ${remote}` }, () => repo.oxy.fetch(repo.root, remote));
642
await repo.refresh();
643
const fresh = info.refs.filter((r) => r.new > 0);
644
const summary = fresh.length ? fresh.map((r) => `${r.name}: ${r.new} new`).join(", ") : "nothing new";
645
vscode.window.setStatusBarMessage(`$(cloud-download) ${remote}: ${summary}`, 5000);
646
if (info.unverified) {
647
void vscode.window.showWarningMessage(`Oxygen: ${remote} has no trust policy; nothing fetched from it was verified.`);
648
}
649
} catch (e) {
650
showError(e);
651
}
652
}
653
654
async function pull(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
655
const repo = repositoryFor(arg);
656
if (!repo) {
657
return;
658
}
659
try {
660
const remote = await pickRemote(repo);
661
if (!remote) {
662
return;
663
}
664
const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pulling from ${remote}` }, () => repo.oxy.pull(repo.root, remote));
665
await repo.refresh();
666
if (!info.changed) {
667
vscode.window.setStatusBarMessage("$(check) Already up to date", 4000);
668
} else if (info.conflicted) {
669
void vscode.window.showWarningMessage("Oxygen: pulled; the result has conflicts to resolve.");
670
} else if (!info.checked_out) {
671
void vscode.window.showInformationMessage("Oxygen: pulled; the working copy was not updated because it has changes. Check out when ready.");
672
} else {
673
vscode.window.setStatusBarMessage(`$(cloud-download) Pulled from ${remote}`, 4000);
674
}
675
} catch (e) {
676
showError(e);
677
}
678
}
679
680
async function push(arg: Repository | vscode.SourceControl | undefined, chooseBookmark: boolean): Promise<void> {
681
const repo = repositoryFor(arg);
682
if (!repo) {
683
return;
684
}
685
try {
686
const remote = await pickRemote(repo);
687
if (!remote) {
688
return;
689
}
690
let bookmark: string | undefined;
691
if (chooseBookmark) {
692
bookmark = await pickBookmark(repo, "Push which bookmark?", false);
693
if (!bookmark) {
694
return;
695
}
696
}
697
if (vscode.workspace.getConfiguration("oxygen").get<boolean>("confirmSync", true)) {
698
const pick = await vscode.window.showInformationMessage(`Push ${bookmark ?? repo.currentBookmark?.name ?? "the current bookmark"} to ${remote}?`, { modal: false }, "Push", "Always push without asking");
699
if (!pick) {
700
return;
701
}
702
if (pick === "Always push without asking") {
703
await vscode.workspace.getConfiguration("oxygen").update("confirmSync", false, vscode.ConfigurationTarget.Global);
704
}
705
}
706
const results = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pushing to ${remote}` }, () => repo.oxy.push(repo.root, remote, bookmark));
707
await repo.refresh();
708
const rejected = results.filter((r) => !r.ok);
709
if (rejected.length === 0) {
710
vscode.window.setStatusBarMessage(`$(cloud-upload) Pushed to ${remote}`, 4000);
711
return;
712
}
713
const reason = rejected.map((r) => `${r.name}: ${r.reason}`).join("; ");
714
const pick = await vscode.window.showErrorMessage(`Oxygen: push rejected. ${reason}`, "Pull", "Show Output");
715
if (pick === "Pull") {
716
await pull(repo);
717
} else if (pick === "Show Output") {
718
output.show();
719
}
720
} catch (e) {
721
if (e instanceof OxyError && e.message.includes("pull first")) {
722
const pick = await vscode.window.showWarningMessage(`Oxygen: ${e.message}`, "Pull");
723
if (pick === "Pull") {
724
await pull(repo);
725
}
726
return;
727
}
728
showError(e);
729
}
730
}
731
732
async function sync(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
733
const repo = repositoryFor(arg);
734
if (!repo) {
735
return;
736
}
737
await pull(repo);
738
if (repo.status?.conflicted) {
739
return;
740
}
741
if ((repo.currentBookmark?.ahead ?? 0) > 0) {
742
await push(repo, false);
743
}
744
}
745
35d56b58 Andy McCall 1 month ago ↶
746
/** The commit message from the input box, else a prompt. */
747
async function askMessage(repo: Repository, prompt: string): Promise<string | undefined> {
748
const fromBox = repo.sourceControl.inputBox.value.trim();
749
if (fromBox) {
750
return fromBox;
751
}
752
return (await vscode.window.showInputBox({ prompt, placeHolder: "Commit message" }))?.trim() || undefined;
753
}
754
bed495a9 Andy McCall 1 month ago ↶
755
async function showLog(): Promise<void> {
756
const repo = repositoryFor(undefined);
757
if (!repo) {
758
return;
759
}
760
try {
761
const log = await repo.oxy.log(repo.root, 50);
762
const items = log.changes
763
.filter((c) => !c.working_copy)
764
.map((c) => ({
765
label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
766
description: `${c.change_id.slice(0, 8)} · ${c.author}`,
767
detail: `${new Date(c.when_ms).toLocaleString()} · ${c.signature?.status ?? "unsigned"}${c.signature?.revoked ? " (key revoked)" : ""}`,
768
change: c,
769
}));
770
const pick = await vscode.window.showQuickPick(items, { placeHolder: `${log.bookmark}: ${log.total - 1} changes`, matchOnDescription: true, matchOnDetail: true });
771
if (pick) {
772
const doc = await vscode.workspace.openTextDocument({ content: pick.change.message, language: "markdown" });
773
await vscode.window.showTextDocument(doc, { preview: true });
774
}
775
} catch (e) {
776
showError(e);
777
}
778
}
779
780
function showError(e: unknown): void {
781
const message = e instanceof OxyError ? e.message : String(e);
782
void vscode.window.showErrorMessage(`Oxygen: ${message}`, "Show Output").then((pick) => pick && output.show());
783
}