// 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.commands.registerCommand("oxygen.switchBookmark", (arg?: Repository | vscode.SourceControl) => switchBookmark(arg)), vscode.commands.registerCommand("oxygen.createBookmark", (arg?: Repository | vscode.SourceControl) => createBookmark(arg)), vscode.commands.registerCommand("oxygen.mergeBookmark", (arg?: Repository | vscode.SourceControl) => mergeBookmark(arg)), vscode.commands.registerCommand("oxygen.deleteBookmark", (arg?: Repository | vscode.SourceControl) => deleteBookmark(arg)), vscode.commands.registerCommand("oxygen.fetch", (arg?: Repository | vscode.SourceControl) => fetch(arg)), vscode.commands.registerCommand("oxygen.pull", (arg?: Repository | vscode.SourceControl) => pull(arg)), vscode.commands.registerCommand("oxygen.push", (arg?: Repository | vscode.SourceControl) => push(arg, false)), vscode.commands.registerCommand("oxygen.pushBookmark", (arg?: Repository | vscode.SourceControl) => push(arg, true)), vscode.commands.registerCommand("oxygen.sync", (arg?: Repository | vscode.SourceControl) => sync(arg)), 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); } } // ---------------------------------------------------------------- bookmarks async function switchBookmark(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } let bookmarks; try { bookmarks = await repo.oxy.bookmarks(repo.root); } catch (e) { showError(e); return; } type Item = vscode.QuickPickItem & { action: "switch" | "create" | "merge" | "delete"; name?: string }; const items: Item[] = bookmarks.map((b) => ({ label: `${b.current ? "$(circle-filled)" : "$(bookmark)"} ${b.name}`, description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}${b.ahead ? ` $(arrow-up)${b.ahead}` : ""}${b.behind ? ` $(arrow-down)${b.behind}` : ""}`, detail: b.current ? "current bookmark" : undefined, action: "switch", name: b.name, })); items.push( { label: "$(add) Create bookmark…", action: "create", alwaysShow: true }, { label: "$(git-merge) Merge a bookmark into the current one…", action: "merge", alwaysShow: true }, { label: "$(trash) Delete a bookmark…", action: "delete", alwaysShow: true }, ); const pick = await vscode.window.showQuickPick(items, { placeHolder: "Switch to a bookmark" }); if (!pick) { return; } switch (pick.action) { case "create": return createBookmark(repo); case "merge": return mergeBookmark(repo); case "delete": return deleteBookmark(repo); case "switch": if (pick.name && !bookmarks.find((b) => b.name === pick.name)?.current) { await doSwitch(repo, pick.name); } } } async function doSwitch(repo: Repository, name: string): Promise { try { await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${name}` }, () => repo.oxy.bookmarkSwitch(repo.root, name)); } catch (e) { if (e instanceof OxyError && e.message.includes("has changes")) { const pick = await vscode.window.showWarningMessage(`The working copy has uncommitted changes. Switching to ${name} would discard them.`, { modal: true }, "Discard and switch"); if (pick === "Discard and switch") { try { await repo.oxy.bookmarkSwitch(repo.root, name, true); } catch (e2) { showError(e2); } } } else { showError(e); } } await repo.refresh(); } async function createBookmark(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } const name = await vscode.window.showInputBox({ prompt: "New bookmark name (created at the working copy's base)", validateInput: (v) => (!v.trim() || v.startsWith("-") || v.includes("..") || v.includes("//") || /[\s@{]/.test(v) ? "Not a valid bookmark name" : undefined), }); if (!name) { return; } try { await repo.oxy.bookmarkCreate(repo.root, name.trim()); const pick = await vscode.window.showInformationMessage(`Oxygen: created bookmark ${name}.`, "Switch to it"); if (pick) { await doSwitch(repo, name.trim()); } await repo.refresh(); } catch (e) { showError(e); } } async function pickBookmark(repo: Repository, placeHolder: string, excludeCurrent: boolean): Promise { const bookmarks = await repo.oxy.bookmarks(repo.root); const items = bookmarks.filter((b) => !excludeCurrent || !b.current).map((b) => ({ label: b.name, description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}` })); if (items.length === 0) { void vscode.window.showInformationMessage("Oxygen: no other bookmarks."); return undefined; } return (await vscode.window.showQuickPick(items, { placeHolder }))?.label; } async function mergeBookmark(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } try { const name = await pickBookmark(repo, `Merge into ${repo.currentBookmark?.name ?? "the current bookmark"}`, true); if (!name) { return; } const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Merging ${name}` }, () => repo.oxy.merge(repo.root, name)); await repo.refresh(); if (repo.status?.conflicted) { void vscode.window.showWarningMessage(`Oxygen: merged ${name}; the result has conflicts to resolve.`); } else { vscode.window.setStatusBarMessage(`$(git-merge) ${out.split("\n")[0]}`, 5000); } } catch (e) { showError(e); } } async function deleteBookmark(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } try { const name = await pickBookmark(repo, "Delete which bookmark? Its changes stay.", true); if (!name) { return; } const pick = await vscode.window.showWarningMessage(`Delete bookmark ${name}?`, { modal: true }, "Delete"); if (pick !== "Delete") { return; } await repo.oxy.bookmarkDelete(repo.root, name); await repo.refresh(); } catch (e) { showError(e); } } // ---------------------------------------------------------------- sync async function pickRemote(repo: Repository): Promise { const remotes = await repo.oxy.remotes(repo.root); if (remotes.length === 0) { void vscode.window.showErrorMessage("Oxygen: this repository has no remotes. Add one with oxy remote add ."); return undefined; } if (remotes.length === 1) { return remotes[0].name; } return (await vscode.window.showQuickPick(remotes.map((r) => ({ label: r.name, description: r.url })), { placeHolder: "Remote" }))?.label; } async function fetch(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } try { const remote = await pickRemote(repo); if (!remote) { return; } const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Fetching from ${remote}` }, () => repo.oxy.fetch(repo.root, remote)); await repo.refresh(); const fresh = info.refs.filter((r) => r.new > 0); const summary = fresh.length ? fresh.map((r) => `${r.name}: ${r.new} new`).join(", ") : "nothing new"; vscode.window.setStatusBarMessage(`$(cloud-download) ${remote}: ${summary}`, 5000); if (info.unverified) { void vscode.window.showWarningMessage(`Oxygen: ${remote} has no trust policy; nothing fetched from it was verified.`); } } catch (e) { showError(e); } } async function pull(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } try { const remote = await pickRemote(repo); if (!remote) { return; } const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pulling from ${remote}` }, () => repo.oxy.pull(repo.root, remote)); await repo.refresh(); if (!info.changed) { vscode.window.setStatusBarMessage("$(check) Already up to date", 4000); } else if (info.conflicted) { void vscode.window.showWarningMessage("Oxygen: pulled; the result has conflicts to resolve."); } else if (!info.checked_out) { void vscode.window.showInformationMessage("Oxygen: pulled; the working copy was not updated because it has changes. Check out when ready."); } else { vscode.window.setStatusBarMessage(`$(cloud-download) Pulled from ${remote}`, 4000); } } catch (e) { showError(e); } } async function push(arg: Repository | vscode.SourceControl | undefined, chooseBookmark: boolean): Promise { const repo = repositoryFor(arg); if (!repo) { return; } try { const remote = await pickRemote(repo); if (!remote) { return; } let bookmark: string | undefined; if (chooseBookmark) { bookmark = await pickBookmark(repo, "Push which bookmark?", false); if (!bookmark) { return; } } if (vscode.workspace.getConfiguration("oxygen").get("confirmSync", true)) { const pick = await vscode.window.showInformationMessage(`Push ${bookmark ?? repo.currentBookmark?.name ?? "the current bookmark"} to ${remote}?`, { modal: false }, "Push", "Always push without asking"); if (!pick) { return; } if (pick === "Always push without asking") { await vscode.workspace.getConfiguration("oxygen").update("confirmSync", false, vscode.ConfigurationTarget.Global); } } const results = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pushing to ${remote}` }, () => repo.oxy.push(repo.root, remote, bookmark)); await repo.refresh(); const rejected = results.filter((r) => !r.ok); if (rejected.length === 0) { vscode.window.setStatusBarMessage(`$(cloud-upload) Pushed to ${remote}`, 4000); return; } const reason = rejected.map((r) => `${r.name}: ${r.reason}`).join("; "); const pick = await vscode.window.showErrorMessage(`Oxygen: push rejected. ${reason}`, "Pull", "Show Output"); if (pick === "Pull") { await pull(repo); } else if (pick === "Show Output") { output.show(); } } catch (e) { if (e instanceof OxyError && e.message.includes("pull first")) { const pick = await vscode.window.showWarningMessage(`Oxygen: ${e.message}`, "Pull"); if (pick === "Pull") { await pull(repo); } return; } showError(e); } } async function sync(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } await pull(repo); if (repo.status?.conflicted) { return; } if ((repo.currentBookmark?.ahead ?? 0) > 0) { await push(repo, false); } } /** 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()); }