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";
8
import { OxyContentProvider, SCHEME, baseUri } from "./contentProvider";
9
import { OxyDecorationProvider } from "./decorations";
35d56b58 Andy McCall 1 month ago ↶
10
import { OperationItem, OperationsProvider } from "./operations";
bed495a9 Andy McCall 1 month ago ↶
11
12
const MIN_VERSION = { format: 2, protocol: 1 };
13
14
let output: vscode.OutputChannel;
15
let oxy: Oxy;
16
let contentProvider: OxyContentProvider;
17
let decorations: OxyDecorationProvider;
35d56b58 Andy McCall 1 month ago ↶
18
let operations: OperationsProvider;
bed495a9 Andy McCall 1 month ago ↶
19
const repositories = new Map<string, Repository>();
20
21
export async function activate(context: vscode.ExtensionContext): Promise<void> {
22
output = vscode.window.createOutputChannel("Oxygen");
23
context.subscriptions.push(output);
810a1c53 Andy McCall 1 month ago ↶
24
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 ↶
25
oxy = new Oxy(findBinary(vscode.workspace.getConfiguration("oxygen").get<string>("path")), (line) => output.appendLine(line));
26
27
try {
28
const v = await oxy.version();
29
output.appendLine(`oxy ${v.version} (format ${v.format}, protocol ${v.protocol}) at ${oxy.binary}`);
30
if (v.format < MIN_VERSION.format || v.protocol < MIN_VERSION.protocol) {
31
void vscode.window.showWarningMessage(`Oxygen: oxy ${v.version} is older than this extension supports.`);
32
}
33
} catch (e) {
34
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");
35
if (pick === "Open Settings") {
36
void vscode.commands.executeCommand("workbench.action.openSettings", "oxygen.path");
37
}
38
return;
39
}
40
41
contentProvider = new OxyContentProvider(oxy);
42
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider));
43
decorations = new OxyDecorationProvider(() => [...repositories.values()]);
44
context.subscriptions.push(decorations);
35d56b58 Andy McCall 1 month ago ↶
45
operations = new OperationsProvider(() => [...repositories.values()]);
46
context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations));
bed495a9 Andy McCall 1 month ago ↶
47
48
context.subscriptions.push(
49
vscode.commands.registerCommand("oxygen.refresh", async () => {
50
await Promise.all([...repositories.values()].map((r) => r.refresh()));
51
}),
52
vscode.commands.registerCommand("oxygen.showOutput", () => output.show()),
53
vscode.commands.registerCommand("oxygen.openFile", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
54
vscode.commands.registerCommand("oxygen.openChanges", (state?: OxyResourceState) => state && openChanges(state)),
55
vscode.commands.registerCommand("oxygen.openBaseVersion", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", baseUri(state.repository.root, state.path))),
56
vscode.commands.registerCommand("oxygen.commit", (arg?: Repository | vscode.SourceControl) => commit(arg)),
57
vscode.commands.registerCommand("oxygen.showLog", () => showLog()),
810a1c53 Andy McCall 1 month ago ↶
58
vscode.commands.registerCommand("oxygen.init", () => initRepository()),
35d56b58 Andy McCall 1 month ago ↶
59
vscode.commands.registerCommand("oxygen.discard", (state?: OxyResourceState, states?: OxyResourceState[]) => discard(states?.length ? states : state ? [state] : [])),
60
vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])),
61
vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)),
62
vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)),
63
vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
64
vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
65
vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
bed495a9 Andy McCall 1 month ago ↶
66
vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
67
vscode.workspace.onDidChangeConfiguration((e) => {
68
if (e.affectsConfiguration("oxygen.path")) {
69
void vscode.window.showInformationMessage("Oxygen: reload the window to use the new oxy path.");
70
}
71
if (e.affectsConfiguration("oxygen")) {
72
decorations.refresh();
73
for (const r of repositories.values()) {
74
r.scheduleRefresh();
75
}
76
}
77
}),
78
);
79
context.subscriptions.push({ dispose: () => repositories.forEach((r) => r.dispose()) });
80
81
await discover();
82
}
83
84
export function deactivate(): void {
85
for (const r of repositories.values()) {
86
r.dispose();
87
}
88
repositories.clear();
89
}
90
91
/** One Repository per distinct oxy root among the workspace folders. */
92
async function discover(): Promise<void> {
93
const wanted = new Map<string, vscode.WorkspaceFolder>();
94
for (const folder of vscode.workspace.workspaceFolders ?? []) {
95
const info = await oxy.root(folder.uri.fsPath);
96
if (info) {
97
wanted.set(info.root, folder);
98
}
99
}
100
for (const [root, repo] of repositories) {
101
if (!wanted.has(root)) {
102
repo.dispose();
103
repositories.delete(root);
104
}
105
}
810a1c53 Andy McCall 1 month ago ↶
106
if (wanted.size === 0 && (vscode.workspace.workspaceFolders ?? []).length > 0) {
107
output.appendLine("no Oxygen repository in the open folders (oxy root found none)");
108
}
bed495a9 Andy McCall 1 month ago ↶
109
for (const [root, folder] of wanted) {
110
if (!repositories.has(root)) {
111
const repo = new Repository(oxy, root, folder);
112
repo.onDidChangeStatus(() => {
113
decorations.refresh();
114
contentProvider.invalidate(root);
35d56b58 Andy McCall 1 month ago ↶
115
operations.refresh();
bed495a9 Andy McCall 1 month ago ↶
116
});
117
repositories.set(root, repo);
118
output.appendLine(`repository ${root}`);
119
await repo.refresh();
120
}
121
}
122
void vscode.commands.executeCommand("setContext", "oxygen.hasRepository", repositories.size > 0);
123
}
124
125
function repositoryFor(arg: Repository | vscode.SourceControl | undefined): Repository | undefined {
126
if (arg instanceof Repository) {
127
return arg;
128
}
129
if (arg && "rootUri" in arg && arg.rootUri) {
130
return repositories.get(arg.rootUri.fsPath);
131
}
132
const active = vscode.window.activeTextEditor?.document.uri;
133
for (const repo of repositories.values()) {
134
if (active && repo.relativePath(active) !== undefined) {
135
return repo;
136
}
137
}
138
return repositories.size === 1 ? [...repositories.values()][0] : undefined;
139
}
140
141
async function openChanges(state: OxyResourceState): Promise<void> {
142
const repo = state.repository;
143
const title = `${basename(state.path)} (base ↔ working copy)`;
144
if (state.status === "Added") {
145
await vscode.commands.executeCommand("vscode.open", state.resourceUri);
146
return;
147
}
148
if (state.status === "Removed") {
149
await vscode.commands.executeCommand("vscode.open", baseUri(repo.root, state.path));
150
return;
151
}
152
await vscode.commands.executeCommand("vscode.diff", baseUri(repo.root, state.path), state.resourceUri, title);
153
}
154
155
async function commit(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
156
const repo = repositoryFor(arg);
157
if (!repo) {
158
void vscode.window.showErrorMessage("Oxygen: no repository for the active file.");
159
return;
160
}
161
let message = repo.sourceControl.inputBox.value.trim();
162
if (!message) {
163
message = (await vscode.window.showInputBox({ prompt: "Commit message", placeHolder: "What did you change?" }))?.trim() ?? "";
164
}
165
if (!message) {
166
return;
167
}
168
try {
169
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message));
170
repo.sourceControl.inputBox.value = "";
171
await repo.refresh();
172
} catch (e) {
173
showError(e);
174
}
175
}
176
810a1c53 Andy McCall 1 month ago ↶
177
async function initRepository(): Promise<void> {
178
const folders = vscode.workspace.workspaceFolders ?? [];
179
if (folders.length === 0) {
180
void vscode.window.showErrorMessage("Oxygen: open a folder first.");
181
return;
182
}
183
const folder = folders.length === 1 ? folders[0] : await vscode.window.showWorkspaceFolderPick({ placeHolder: "Folder to initialize as an Oxygen repository" });
184
if (!folder) {
185
return;
186
}
187
try {
188
await oxy.check(folder.uri.fsPath, ["init"]);
189
await discover();
190
void vscode.window.showInformationMessage(`Oxygen: initialized ${folder.name}.`);
191
} catch (e) {
192
showError(e);
193
}
194
}
195
35d56b58 Andy McCall 1 month ago ↶
196
async function discard(states: OxyResourceState[]): Promise<void> {
197
if (states.length === 0) {
198
return;
199
}
200
const repo = states[0].repository;
201
const names = states.length === 1 ? basename(states[0].path) : `${states.length} files`;
202
const pick = await vscode.window.showWarningMessage(`Discard changes in ${names}? This cannot be undone with oxy undo.`, { modal: true }, "Discard");
203
if (pick !== "Discard") {
204
return;
205
}
206
try {
207
await repo.oxy.discard(repo.root, states.map((s) => s.path));
208
await repo.refresh();
209
} catch (e) {
210
showError(e);
211
}
212
}
213
214
async function commitSelected(states: OxyResourceState[]): Promise<void> {
215
if (states.length === 0) {
216
return;
217
}
218
const repo = states[0].repository;
219
const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`);
220
if (!message) {
221
return;
222
}
223
try {
224
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path)));
225
repo.sourceControl.inputBox.value = "";
226
await repo.refresh();
227
} catch (e) {
228
showError(e);
229
}
230
}
231
232
async function commitHunks(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
233
const repo = repositoryFor(arg);
234
if (!repo) {
235
return;
236
}
237
let hunks;
238
try {
239
hunks = await repo.oxy.hunks(repo.root);
240
} catch (e) {
241
showError(e);
242
return;
243
}
244
if (hunks.length === 0) {
245
void vscode.window.showInformationMessage("Oxygen: nothing to commit.");
246
return;
247
}
248
const picks = await vscode.window.showQuickPick(
249
hunks.map((h) => ({
250
label: `$(diff) ${h.path}`,
251
description: `#${h.index}`,
252
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),
253
hunk: h,
254
picked: false,
255
})),
256
{ canPickMany: true, placeHolder: "Choose the hunks to commit; anything a chosen hunk needs comes along" },
257
);
258
if (!picks || picks.length === 0) {
259
return;
260
}
261
const message = await askMessage(repo, `Commit ${picks.length} hunk${picks.length === 1 ? "" : "s"}`);
262
if (!message) {
263
return;
264
}
265
try {
266
await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index));
267
repo.sourceControl.inputBox.value = "";
268
await repo.refresh();
269
} catch (e) {
270
showError(e);
271
}
272
}
273
274
async function amend(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
275
const repo = repositoryFor(arg);
276
if (!repo) {
277
return;
278
}
279
const message = repo.sourceControl.inputBox.value.trim();
280
try {
281
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined));
282
repo.sourceControl.inputBox.value = "";
283
await repo.refresh();
284
} catch (e) {
285
showError(e);
286
}
287
}
288
289
async function undo(arg: Repository | vscode.SourceControl | undefined, steps: number): Promise<void> {
290
const repo = repositoryFor(arg);
291
if (!repo || steps <= 0) {
292
return;
293
}
294
if (steps > 1) {
295
const pick = await vscode.window.showWarningMessage(`Undo the last ${steps} operations?`, { modal: true }, "Undo");
296
if (pick !== "Undo") {
297
return;
298
}
299
}
300
try {
301
let last = "";
302
for (let i = 0; i < steps; i++) {
303
last = await repo.oxy.undo(repo.root);
304
}
305
await repo.refresh();
306
vscode.window.setStatusBarMessage(`$(discard) ${last || "Undone"}`, 4000);
307
} catch (e) {
308
showError(e);
309
}
310
}
311
312
/** The commit message from the input box, else a prompt. */
313
async function askMessage(repo: Repository, prompt: string): Promise<string | undefined> {
314
const fromBox = repo.sourceControl.inputBox.value.trim();
315
if (fromBox) {
316
return fromBox;
317
}
318
return (await vscode.window.showInputBox({ prompt, placeHolder: "Commit message" }))?.trim() || undefined;
319
}
320
bed495a9 Andy McCall 1 month ago ↶
321
async function showLog(): Promise<void> {
322
const repo = repositoryFor(undefined);
323
if (!repo) {
324
return;
325
}
326
try {
327
const log = await repo.oxy.log(repo.root, 50);
328
const items = log.changes
329
.filter((c) => !c.working_copy)
330
.map((c) => ({
331
label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
332
description: `${c.change_id.slice(0, 8)} · ${c.author}`,
333
detail: `${new Date(c.when_ms).toLocaleString()} · ${c.signature?.status ?? "unsigned"}${c.signature?.revoked ? " (key revoked)" : ""}`,
334
change: c,
335
}));
336
const pick = await vscode.window.showQuickPick(items, { placeHolder: `${log.bookmark}: ${log.total - 1} changes`, matchOnDescription: true, matchOnDetail: true });
337
if (pick) {
338
const doc = await vscode.workspace.openTextDocument({ content: pick.change.message, language: "markdown" });
339
await vscode.window.showTextDocument(doc, { preview: true });
340
}
341
} catch (e) {
342
showError(e);
343
}
344
}
345
346
function showError(e: unknown): void {
347
const message = e instanceof OxyError ? e.message : String(e);
348
void vscode.window.showErrorMessage(`Oxygen: ${message}`, "Show Output").then((pick) => pick && output.show());
349
}