OXYGENHUB
# 35d56b5803d9 oxygen-vscode / src / extension.ts
13.6 KiB · last changed Milestone 0.2: discard, commit selected paths, commit chosen hunks, amend, undo, Oxygen Operations view with undo to here 1 month ago source blame history raw
1
// Activation: find oxy, find repositories, wire the source control view,
2
// decorations, the content provider, and the commands (spec §3).
3
4
import * as vscode from "vscode";
5
import { 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";
10
import { OperationItem, OperationsProvider } from "./operations";
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;
18
let operations: OperationsProvider;
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);
24
  output.appendLine(`Oxygen extension ${context.extension.packageJSON.version} activating; folders: ${(vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath).join(", ") || "(none)"}`);
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);
45
  operations = new OperationsProvider(() => [...repositories.values()]);
46
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations));
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()),
58
    vscode.commands.registerCommand("oxygen.init", () => initRepository()),
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()),
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
  }
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
  }
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);
115
        operations.refresh();
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
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
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
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
}