// Activation: find oxy, find repositories, wire the source control view, // decorations, the content provider, and the commands (spec §3). import * as vscode from "vscode"; import { basename } from "node:path"; import { Oxy, OxyError, findBinary } from "./oxy"; import { Repository, OxyResourceState } from "./repository"; import { OxyContentProvider, SCHEME, baseUri } from "./contentProvider"; import { OxyDecorationProvider } from "./decorations"; import { OperationItem, OperationsProvider } from "./operations"; const MIN_VERSION = { format: 2, protocol: 1 }; let output: vscode.OutputChannel; let oxy: Oxy; let contentProvider: OxyContentProvider; let decorations: OxyDecorationProvider; let operations: OperationsProvider; const repositories = new Map(); export async function activate(context: vscode.ExtensionContext): Promise { output = vscode.window.createOutputChannel("Oxygen"); context.subscriptions.push(output); output.appendLine(`Oxygen extension ${context.extension.packageJSON.version} activating; folders: ${(vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath).join(", ") || "(none)"}`); oxy = new Oxy(findBinary(vscode.workspace.getConfiguration("oxygen").get("path")), (line) => output.appendLine(line)); try { const v = await oxy.version(); output.appendLine(`oxy ${v.version} (format ${v.format}, protocol ${v.protocol}) at ${oxy.binary}`); if (v.format < MIN_VERSION.format || v.protocol < MIN_VERSION.protocol) { void vscode.window.showWarningMessage(`Oxygen: oxy ${v.version} is older than this extension supports.`); } } catch (e) { 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"); if (pick === "Open Settings") { void vscode.commands.executeCommand("workbench.action.openSettings", "oxygen.path"); } return; } contentProvider = new OxyContentProvider(oxy); context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider)); decorations = new OxyDecorationProvider(() => [...repositories.values()]); context.subscriptions.push(decorations); operations = new OperationsProvider(() => [...repositories.values()]); context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations)); context.subscriptions.push( vscode.commands.registerCommand("oxygen.refresh", async () => { await Promise.all([...repositories.values()].map((r) => r.refresh())); }), vscode.commands.registerCommand("oxygen.showOutput", () => output.show()), vscode.commands.registerCommand("oxygen.openFile", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)), vscode.commands.registerCommand("oxygen.openChanges", (state?: OxyResourceState) => state && openChanges(state)), vscode.commands.registerCommand("oxygen.openBaseVersion", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", baseUri(state.repository.root, state.path))), vscode.commands.registerCommand("oxygen.commit", (arg?: Repository | vscode.SourceControl) => commit(arg)), vscode.commands.registerCommand("oxygen.showLog", () => showLog()), vscode.commands.registerCommand("oxygen.init", () => initRepository()), vscode.commands.registerCommand("oxygen.discard", (state?: OxyResourceState, states?: OxyResourceState[]) => discard(states?.length ? states : state ? [state] : [])), vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])), vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)), vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)), vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)), vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)), vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()), vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()), vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("oxygen.path")) { void vscode.window.showInformationMessage("Oxygen: reload the window to use the new oxy path."); } if (e.affectsConfiguration("oxygen")) { decorations.refresh(); for (const r of repositories.values()) { r.scheduleRefresh(); } } }), ); context.subscriptions.push({ dispose: () => repositories.forEach((r) => r.dispose()) }); await discover(); } export function deactivate(): void { for (const r of repositories.values()) { r.dispose(); } repositories.clear(); } /** One Repository per distinct oxy root among the workspace folders. */ async function discover(): Promise { const wanted = new Map(); for (const folder of vscode.workspace.workspaceFolders ?? []) { const info = await oxy.root(folder.uri.fsPath); if (info) { wanted.set(info.root, folder); } } for (const [root, repo] of repositories) { if (!wanted.has(root)) { repo.dispose(); repositories.delete(root); } } if (wanted.size === 0 && (vscode.workspace.workspaceFolders ?? []).length > 0) { output.appendLine("no Oxygen repository in the open folders (oxy root found none)"); } for (const [root, folder] of wanted) { if (!repositories.has(root)) { const repo = new Repository(oxy, root, folder); repo.onDidChangeStatus(() => { decorations.refresh(); contentProvider.invalidate(root); operations.refresh(); }); repositories.set(root, repo); output.appendLine(`repository ${root}`); await repo.refresh(); } } void vscode.commands.executeCommand("setContext", "oxygen.hasRepository", repositories.size > 0); } function repositoryFor(arg: Repository | vscode.SourceControl | undefined): Repository | undefined { if (arg instanceof Repository) { return arg; } if (arg && "rootUri" in arg && arg.rootUri) { return repositories.get(arg.rootUri.fsPath); } const active = vscode.window.activeTextEditor?.document.uri; for (const repo of repositories.values()) { if (active && repo.relativePath(active) !== undefined) { return repo; } } return repositories.size === 1 ? [...repositories.values()][0] : undefined; } async function openChanges(state: OxyResourceState): Promise { const repo = state.repository; const title = `${basename(state.path)} (base ↔ working copy)`; if (state.status === "Added") { await vscode.commands.executeCommand("vscode.open", state.resourceUri); return; } if (state.status === "Removed") { await vscode.commands.executeCommand("vscode.open", baseUri(repo.root, state.path)); return; } await vscode.commands.executeCommand("vscode.diff", baseUri(repo.root, state.path), state.resourceUri, title); } async function commit(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { void vscode.window.showErrorMessage("Oxygen: no repository for the active file."); return; } let message = repo.sourceControl.inputBox.value.trim(); if (!message) { message = (await vscode.window.showInputBox({ prompt: "Commit message", placeHolder: "What did you change?" }))?.trim() ?? ""; } if (!message) { return; } try { await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message)); repo.sourceControl.inputBox.value = ""; await repo.refresh(); } catch (e) { showError(e); } } async function initRepository(): Promise { const folders = vscode.workspace.workspaceFolders ?? []; if (folders.length === 0) { void vscode.window.showErrorMessage("Oxygen: open a folder first."); return; } const folder = folders.length === 1 ? folders[0] : await vscode.window.showWorkspaceFolderPick({ placeHolder: "Folder to initialize as an Oxygen repository" }); if (!folder) { return; } try { await oxy.check(folder.uri.fsPath, ["init"]); await discover(); void vscode.window.showInformationMessage(`Oxygen: initialized ${folder.name}.`); } catch (e) { showError(e); } } async function discard(states: OxyResourceState[]): Promise { if (states.length === 0) { return; } const repo = states[0].repository; const names = states.length === 1 ? basename(states[0].path) : `${states.length} files`; const pick = await vscode.window.showWarningMessage(`Discard changes in ${names}? This cannot be undone with oxy undo.`, { modal: true }, "Discard"); if (pick !== "Discard") { return; } try { await repo.oxy.discard(repo.root, states.map((s) => s.path)); await repo.refresh(); } catch (e) { showError(e); } } async function commitSelected(states: OxyResourceState[]): Promise { if (states.length === 0) { return; } const repo = states[0].repository; const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`); if (!message) { return; } try { await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path))); repo.sourceControl.inputBox.value = ""; await repo.refresh(); } catch (e) { showError(e); } } async function commitHunks(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } let hunks; try { hunks = await repo.oxy.hunks(repo.root); } catch (e) { showError(e); return; } if (hunks.length === 0) { void vscode.window.showInformationMessage("Oxygen: nothing to commit."); return; } const picks = await vscode.window.showQuickPick( hunks.map((h) => ({ label: `$(diff) ${h.path}`, description: `#${h.index}`, 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), hunk: h, picked: false, })), { canPickMany: true, placeHolder: "Choose the hunks to commit; anything a chosen hunk needs comes along" }, ); if (!picks || picks.length === 0) { return; } const message = await askMessage(repo, `Commit ${picks.length} hunk${picks.length === 1 ? "" : "s"}`); if (!message) { return; } try { await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index)); repo.sourceControl.inputBox.value = ""; await repo.refresh(); } catch (e) { showError(e); } } async function amend(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } const message = repo.sourceControl.inputBox.value.trim(); try { await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined)); repo.sourceControl.inputBox.value = ""; await repo.refresh(); } catch (e) { showError(e); } } async function undo(arg: Repository | vscode.SourceControl | undefined, steps: number): Promise { const repo = repositoryFor(arg); if (!repo || steps <= 0) { return; } if (steps > 1) { const pick = await vscode.window.showWarningMessage(`Undo the last ${steps} operations?`, { modal: true }, "Undo"); if (pick !== "Undo") { return; } } try { let last = ""; for (let i = 0; i < steps; i++) { last = await repo.oxy.undo(repo.root); } await repo.refresh(); vscode.window.setStatusBarMessage(`$(discard) ${last || "Undone"}`, 4000); } catch (e) { showError(e); } } /** The commit message from the input box, else a prompt. */ async function askMessage(repo: Repository, prompt: string): Promise { const fromBox = repo.sourceControl.inputBox.value.trim(); if (fromBox) { return fromBox; } return (await vscode.window.showInputBox({ prompt, placeHolder: "Commit message" }))?.trim() || undefined; } async function showLog(): Promise { const repo = repositoryFor(undefined); if (!repo) { return; } try { const log = await repo.oxy.log(repo.root, 50); const items = log.changes .filter((c) => !c.working_copy) .map((c) => ({ label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`, description: `${c.change_id.slice(0, 8)} · ${c.author}`, detail: `${new Date(c.when_ms).toLocaleString()} · ${c.signature?.status ?? "unsigned"}${c.signature?.revoked ? " (key revoked)" : ""}`, change: c, })); const pick = await vscode.window.showQuickPick(items, { placeHolder: `${log.bookmark}: ${log.total - 1} changes`, matchOnDescription: true, matchOnDetail: true }); if (pick) { const doc = await vscode.workspace.openTextDocument({ content: pick.change.message, language: "markdown" }); await vscode.window.showTextDocument(doc, { preview: true }); } } catch (e) { showError(e); } } function showError(e: unknown): void { const message = e instanceof OxyError ? e.message : String(e); void vscode.window.showErrorMessage(`Oxygen: ${message}`, "Show Output").then((pick) => pick && output.show()); }