// 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 { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, linkedLines, pickProvider, repositoryFor as rootFor, stateSpec } from "./api"; import { basename, join } from "node:path"; import { Oxy, OxyError, ShowInfo, findBinary } from "./oxy"; import { Repository, OxyResourceState, SubrepoState } from "./repository"; import { BisectProvider, TrustProvider } from "./panels"; import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider"; import { OxyDecorationProvider } from "./decorations"; import { OperationItem, OperationsProvider, timeAgo } from "./operations"; import { BlameController } from "./blame"; import { HistoryPanel } from "./history"; import { MessageHistory, conflictMarkerLines, placeholder, signingLabel } from "./commitbox"; import { readFile, stat } from "node:fs/promises"; import { ConflictCodeLensProvider, acceptSides, keepCandidate, markResolved } from "./conflicts"; import { parsePlanQuery, parseResolveQuery, sameRemote } from "./handoff"; const MIN_VERSION = { format: 2, protocol: 1 }; let output: vscode.OutputChannel; let oxy: Oxy; let contentProvider: OxyContentProvider; let decorations: OxyDecorationProvider; let operations: OperationsProvider; let blame: BlameController; let conflictLenses: ConflictCodeLensProvider; let bisectView: BisectProvider; let trustView: TrustProvider; const repositories = new Map(); export async function activate(context: vscode.ExtensionContext): Promise { extensionContext = context; 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)); blame = new BlameController(() => [...repositories.values()]); context.subscriptions.push(blame); conflictLenses = new ConflictCodeLensProvider(() => [...repositories.values()]); context.subscriptions.push(vscode.languages.registerCodeLensProvider({ scheme: "file" }, conflictLenses)); bisectView = new BisectProvider(() => [...repositories.values()]); trustView = new TrustProvider(() => [...repositories.values()]); context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.bisect", bisectView), vscode.window.registerTreeDataProvider("oxygen.trust", trustView)); 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.openOnRemote", (arg?: Repository | vscode.SourceControl | LinkArg) => openOnRemote(undefined, arg instanceof Repository ? arg : undefined, "open", arg instanceof Repository || isSourceControl(arg) ? undefined : (arg as LinkArg)), ), vscode.commands.registerCommand("oxygen.copyRemoteLink", (arg?: LinkArg) => openOnRemote(undefined, undefined, "copy", arg)), vscode.commands.registerCommand("oxygen.copyPermalink", (arg?: LinkArg) => openOnRemote(undefined, undefined, "permalink", arg)), vscode.commands.registerCommand("oxygen.openChangeOnRemote", (root: string, hash: string) => openOnRemote({ kind: "change", hash }, repositories.get(root))), 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.history.split", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "split")), vscode.commands.registerCommand("oxygen.history.squash", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "squash")), vscode.commands.registerCommand("oxygen.history.rebase", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "rebase")), vscode.commands.registerCommand("oxygen.amendMode", (arg?: Repository | vscode.SourceControl) => toggleAmend(arg, true)), vscode.commands.registerCommand("oxygen.leaveAmendMode", (arg?: Repository | vscode.SourceControl) => toggleAmend(arg, false)), vscode.commands.registerCommand("oxygen.messageHistory", (arg?: Repository | vscode.SourceControl) => messageHistory(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.commands.registerCommand("oxygen.showHistory", (arg?: Repository | vscode.SourceControl) => { const repo = repositoryFor(arg); if (repo) { HistoryPanel.show(repo, (hash) => void showChange(repo.root, hash)); } }), vscode.commands.registerCommand("oxygen.fileHistory", (uri?: vscode.Uri) => fileHistory(uri ?? vscode.window.activeTextEditor?.document.uri)), vscode.commands.registerCommand("oxygen.toggleBlame", () => blame.toggle()), vscode.commands.registerCommand("oxygen.showChange", (root?: string, hash?: string) => root && hash && showChange(root, hash)), vscode.commands.registerCommand("oxygen.acceptSides", (uri: vscode.Uri, index: number, keep: number[]) => acceptSides(uri, index, keep).catch(showError)), vscode.commands.registerCommand("oxygen.keepCandidate", (state?: OxyResourceState) => state && keepCandidate(state.repository, state.path).catch(showError)), vscode.commands.registerCommand("oxygen.markResolved", (arg?: Repository | vscode.SourceControl) => { const repo = repositoryFor(arg); return repo && markResolved(repo).catch(showError); }), vscode.commands.registerCommand("oxygen.openConflict", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)), vscode.commands.registerCommand("oxygen.openSubrepo", (state?: SubrepoState) => state && openSubrepo(state)), vscode.commands.registerCommand("oxygen.updateSubrepo", (state?: SubrepoState) => state && updateSubrepos(state.repository, true, [state.info.path])), vscode.commands.registerCommand("oxygen.updateSubrepos", (arg?: Repository | vscode.SourceControl) => { const repo = repositoryFor(arg); return repo && updateSubrepos(repo, true, []); }), vscode.commands.registerCommand("oxygen.bisectStart", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectStart(r.root).then(() => "Started; mark a good and a bad change"))), vscode.commands.registerCommand("oxygen.bisectGood", (arg?: Repository, changes?: string[]) => bisectMark(arg, "good", changes)), vscode.commands.registerCommand("oxygen.bisectBad", (arg?: Repository, changes?: string[]) => bisectMark(arg, "bad", changes)), vscode.commands.registerCommand("oxygen.bisectSkip", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectMark(r.root, "skip"))), vscode.commands.registerCommand("oxygen.bisectRun", (arg?: Repository) => bisectRun(arg)), vscode.commands.registerCommand("oxygen.bisectReset", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectReset(r.root).then(() => "Bisect reset; working copy restored"))), vscode.commands.registerCommand("oxygen.refreshBisect", () => bisectView.refresh()), vscode.commands.registerCommand("oxygen.refreshTrust", () => trustView.refresh()), vscode.commands.registerCommand("oxygen.verify", (arg?: Repository | vscode.SourceControl) => verify(arg)), vscode.commands.registerCommand("oxygen.tags", (arg?: Repository | vscode.SourceControl) => tags(arg)), vscode.commands.registerCommand("oxygen.createTag", (arg?: Repository | vscode.SourceControl) => createTag(arg)), vscode.commands.registerCommand("oxygen.addWorkspace", (arg?: Repository | vscode.SourceControl) => addWorkspace(arg)), vscode.commands.registerCommand("oxygen.clone", () => cloneRepository()), vscode.commands.registerCommand("oxygen.createIdentity", () => createIdentity()), vscode.window.registerUriHandler({ handleUri: (uri) => handleUri(uri) }), 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(); return buildApi(); } // ---------------------------------------------------------------- exported API const remoteProviders: RemoteProvider[] = []; const onDidChangeRepositoriesEmitter = new vscode.EventEmitter(); const onDidChangeStatusEmitter = new vscode.EventEmitter(); function handleOf(repo: Repository): RepositoryHandle { return { root: repo.root, get status() { return repo.status; }, get bookmarks() { return repo.bookmarks.map((b) => ({ name: b.name, heads: b.heads, current: b.current })); }, get currentBookmark() { return repo.currentBookmark?.name; }, remotes: async () => (await repo.oxy.remotes(repo.root)).map((r) => ({ name: r.name, url: r.url, ca: r.ca ?? undefined })), json: (args: string[]) => repo.oxy.json(repo.root, args), refresh: () => repo.refresh(), }; } function buildApi(): OxygenAPI { return { version: 1, get repositories() { return [...repositories.values()].map(handleOf); }, onDidChangeStatus: (listener) => onDidChangeStatusEmitter.event(listener), onDidChangeRepositories: (listener) => onDidChangeRepositoriesEmitter.event(listener), getRepository: (fsPath) => { const root = rootFor([...repositories.keys()], fsPath); const repo = root ? repositories.get(root) : undefined; return repo ? handleOf(repo) : undefined; }, registerRemoteProvider: (provider) => { remoteProviders.push(provider); void vscode.commands.executeCommand("setContext", "oxygen.hasRemoteProvider", true); return { dispose: () => { const i = remoteProviders.indexOf(provider); if (i >= 0) { remoteProviders.splice(i, 1); } void vscode.commands.executeCommand("setContext", "oxygen.hasRemoteProvider", remoteProviders.length > 0); }, }; }, remoteProviders: () => [...remoteProviders], }; } /** What a remote-link command does with the URL. */ type LinkMode = "open" | "copy" | "permalink"; /** The editor gutter passes `{ lineNumber, uri }`; an editor title menu passes the file's Uri. */ type LinkArg = { lineNumber?: number; uri?: vscode.Uri } | vscode.Uri | undefined; /** * Open or copy a link to the current repository, file or change on * whichever provider knows its remote. For a file, the lines are the * gutter's clicked line or the selection; `permalink` pins the link to the * bookmark's current state (its heads), so it keeps pointing at these lines. */ async function openOnRemote(target?: RemoteTarget, repoArg?: Repository, mode: LinkMode = "open", arg?: LinkArg): Promise { const argUri = arg instanceof vscode.Uri ? arg : arg?.uri; const clicked = arg && !(arg instanceof vscode.Uri) ? arg.lineNumber : undefined; const editor = vscode.window.activeTextEditor; const docUri = argUri ?? editor?.document.uri; const repo = repoArg ?? (docUri ? repositoryFor2(docUri) : undefined) ?? [...repositories.values()][0]; if (!repo) { void vscode.window.showInformationMessage("Oxygen: no repository is open."); return; } const remotes = (await repo.oxy.remotes(repo.root)).map((r) => ({ name: r.name, url: r.url })); const pick = pickProvider(remoteProviders, remotes); if (!pick) { void vscode.window.showInformationMessage("Oxygen: no extension knows this repository's remotes. Install a forge extension such as OxygenHub."); return; } let t: RemoteTarget = target ?? { kind: "repository" }; let note = ""; if (!target && docUri) { const rel = repo.relativePath(docUri); if (rel) { const bookmark = repo.currentBookmark; let reference = bookmark?.name ?? "main"; if (mode === "permalink") { const spec = stateSpec(bookmark?.heads ?? []); if (!spec) { void vscode.window.showInformationMessage("Oxygen: this bookmark has no committed state to link to yet."); return; } reference = spec; } const sameDoc = editor && editor.document.uri.toString() === docUri.toString(); const sel = sameDoc ? { start: editor.selection.start.line + 1, end: editor.selection.end.line + 1, endColumn: editor.selection.end.character, empty: editor.selection.isEmpty } : { start: clicked ?? 1, end: clicked ?? 1, endColumn: 0, empty: true }; const lines = clicked !== undefined || sameDoc ? linkedLines(clicked, sel) : {}; t = { kind: "file", reference, path: rel, ...lines }; // Lines are counted in the editor; say so when they may not match the remote. const edited = repo.status?.paths.some((p) => p.path === rel); if (edited || (sameDoc && editor.document.isDirty)) { note = " The file has uncommitted edits, so the lines may differ from the remote's."; } else if (mode !== "permalink" && bookmark && bookmark.ahead !== null && bookmark.ahead > 0) { note = ` ${bookmark.name} has unpushed changes; the remote may not show them yet.`; } } } const url = pick.provider.urlFor(pick.remote.url, t); if (!url) { return; } if (mode === "open") { await vscode.env.openExternal(vscode.Uri.parse(url)); if (note) { void vscode.window.showInformationMessage(`Oxygen:${note}`); } return; } await vscode.env.clipboard.writeText(url); const what = mode === "permalink" ? "Permalink" : "Link"; if (note) { void vscode.window.showWarningMessage(`Oxygen: ${what} copied.${note}`); } else { vscode.window.setStatusBarMessage(`$(link) ${what} copied`, 4000); } } function isSourceControl(arg: unknown): arg is vscode.SourceControl { return typeof arg === "object" && arg !== null && "inputBox" in arg && "rootUri" in arg; } function repositoryFor2(uri: vscode.Uri): Repository | undefined { const root = rootFor([...repositories.keys()], uri.fsPath); return root ? repositories.get(root) : undefined; } 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); onDidChangeRepositoriesEmitter.fire(); } } 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((status) => { decorations.refresh(); contentProvider.invalidate(root); operations.refresh(); blame.invalidate(root); HistoryPanel.refresh(root); conflictLenses.refresh(); bisectView.refresh(); trustView.refresh(); // The commit action reads as a resolution while conflicts are resolved but uncommitted. box(repo).resolution = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true); void refreshSigning(repo); updateBox(repo); }); repositories.set(root, repo); repo.onDidChangeStatus(() => onDidChangeStatusEmitter.fire(handleOf(repo))); onDidChangeRepositoriesEmitter.fire(); 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; } if (box(repo).amend) { await amend(repo); 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; } if (!(await markersOk(repo))) { return; } try { await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message)); remember(repo, 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; } if (!(await markersOk(repo, states.map((s) => s.path)))) { return; } try { await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path))); remember(repo, message); 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)); remember(repo, message); 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(); if (!(await markersOk(repo))) { return; } try { await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined)); remember(repo, message); repo.sourceControl.inputBox.value = ""; setAmend(repo, false); 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); } } // ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces, clone async function openSubrepo(state: SubrepoState): Promise { if (!state.info.actual) { const pick = await vscode.window.showInformationMessage(`${state.info.path} is not checked out yet.`, "Update --init"); if (pick) { await updateSubrepos(state.repository, true, [state.info.path]); } return; } const pick = await vscode.window.showQuickPick( [ { label: "$(folder-opened) Open in new window", action: "window" }, { label: "$(cloud-download) Update to the pin", action: "update", description: state.info.at_pin ? "already at pin" : "moves the nested repository" }, ], { placeHolder: `${state.info.path} · ${state.decorations.tooltip}` }, ); if (pick?.action === "window") { await vscode.commands.executeCommand("vscode.openFolder", state.resourceUri, { forceNewWindow: true }); } else if (pick?.action === "update") { await updateSubrepos(state.repository, true, [state.info.path]); } } async function updateSubrepos(repo: Repository, init: boolean, paths: string[]): Promise { try { const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Updating subrepos" }, () => repo.oxy.submoduleUpdate(repo.root, init, paths)); await repo.refresh(); const lines = out.trim().split("\n").filter(Boolean); if (lines.some((l) => /missing|uncommitted|✖|x /.test(l))) { void vscode.window.showWarningMessage(`Oxygen: ${lines.join("; ")}`); } else { vscode.window.setStatusBarMessage(`$(check) ${lines.join("; ") || "Subrepos up to date"}`, 5000); } } catch (e) { showError(e); } } async function bisect(arg: Repository | undefined, run: (repo: Repository) => Promise): Promise { const repo = arg instanceof Repository ? arg : repositoryFor(undefined); if (!repo) { return; } try { const out = await run(repo); await repo.refresh(); vscode.window.setStatusBarMessage(`$(search) ${out.split("\n")[0]}`, 5000); } catch (e) { showError(e); } } async function bisectMark(arg: Repository | undefined, verdict: "good" | "bad", changes?: string[]): Promise { const repo = arg instanceof Repository ? arg : repositoryFor(undefined); if (!repo) { return; } let picked = changes; if (picked === undefined) { const status = await repo.oxy.bisectStatus(repo.root).catch(() => null); if (!status || status.state === "need-marks") { const value = await vscode.window.showInputBox({ prompt: `A change, bookmark or tag known to be ${verdict}`, placeHolder: verdict === "good" ? "v1.2 or a ChangeId prefix" : "main" }); if (!value?.trim()) { return; } picked = value.trim().split(/\s+/); } else { picked = []; } } await bisect(repo, (r) => r.oxy.bisectMark(r.root, verdict, picked)); } async function bisectRun(arg: Repository | undefined): Promise { const repo = arg instanceof Repository ? arg : repositoryFor(undefined); if (!repo) { return; } const value = await vscode.window.showInputBox({ prompt: "Command to run for each step (exit 0 good, 125 skip, anything else bad)", placeHolder: "make test" }); if (!value?.trim()) { return; } await bisect(repo, (r) => Promise.resolve(vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Oxygen: bisecting", cancellable: false }, () => r.oxy.bisectRun(r.root, ["sh", "-c", value.trim()])))); } async function verify(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } try { const results = await repo.oxy.verify(repo.root); const icon = (s: string, revoked: boolean) => (s === "invalid" ? "$(error)" : s === "unsigned" ? "$(circle-outline)" : revoked ? "$(warning)" : "$(verified)"); const pick = await vscode.window.showQuickPick( results.map((r) => ({ label: `${icon(r.status, r.revoked)} ${r.change_id.slice(0, 8)} ${r.status}${r.revoked ? " (key revoked)" : ""}`, description: r.name ?? "", detail: r.reason ?? undefined, hash: r.hash, })), { placeHolder: `${results.length} changes; ${results.filter((r) => r.status === "invalid").length} invalid`, matchOnDescription: true }, ); if (pick) { await showChange(repo.root, pick.hash); } } catch (e) { showError(e); } } async function tags(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } try { const list = await repo.oxy.tags(repo.root); if (list.length === 0) { const pick = await vscode.window.showInformationMessage("Oxygen: no tags yet.", "Create tag"); if (pick) { await createTag(repo); } return; } const pick = await vscode.window.showQuickPick( list.map((t) => ({ label: `$(tag) ${t.name}`, description: `${t.heads.length} head${t.heads.length === 1 ? "" : "s"}${t.signer ? ` · signed by ${t.signer}` : ""}`, detail: t.message || undefined, tag: t })), { placeHolder: "Check out a tag's state" }, ); if (!pick) { return; } await repo.oxy.checkout(repo.root, [pick.tag.name]); await repo.refresh(); } catch (e) { if (e instanceof OxyError && e.message.includes("has changes")) { void vscode.window.showWarningMessage("Oxygen: commit or discard your changes before checking out a tag."); return; } showError(e); } } async function createTag(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } const name = await vscode.window.showInputBox({ prompt: "Tag name (tags the current bookmark's heads)" }); if (!name?.trim()) { return; } const message = (await vscode.window.showInputBox({ prompt: "Tag message (optional)" })) ?? ""; try { await repo.oxy.tagCreate(repo.root, name.trim(), [], message); await repo.refresh(); vscode.window.setStatusBarMessage(`$(tag) Tagged ${name.trim()}`, 4000); } catch (e) { showError(e); } } async function addWorkspace(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } const folders = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Create workspace here", title: "Folder for the new workspace (must be empty or absent)" }); if (!folders?.[0]) { return; } try { await repo.oxy.workspaceAdd(repo.root, folders[0].fsPath); const pick = await vscode.window.showInformationMessage(`Oxygen: workspace created at ${folders[0].fsPath}.`, "Open in new window"); if (pick) { await vscode.commands.executeCommand("vscode.openFolder", folders[0], { forceNewWindow: true }); } } catch (e) { showError(e); } } /** * vscode://oxygenhub.oxygen-vscode/resolve?url=…&bookmark=…&path=… from * OxygenHub's conflict resolver: find the open repository with that remote * (or offer to clone it), switch to the bookmark if asked to, pull it, and * open the file, where the conflict lenses take over. */ async function handleUri(uri: vscode.Uri): Promise { if (uri.path === "/plan") { return applyPlan(uri); } if (uri.path !== "/resolve") { return; } const req = parseResolveQuery(uri.query); if (typeof req === "string") { vscode.window.showErrorMessage(`Oxygen: cannot open this conflict: ${req}.`); return; } let found: { repo: Repository; remote: string } | undefined; for (const repo of repositories.values()) { const remotes = await repo.oxy.remotes(repo.root).catch(() => []); const r = remotes.find((x) => sameRemote(x.url, req.url)); if (r) { found = { repo, remote: r.name }; break; } } if (!found) { const pick = await vscode.window.showInformationMessage( `Oxygen: no open folder is a checkout of ${req.url}. Clone it, open it, then follow the link again.`, "Clone…", ); if (pick) { await cloneRepository(req.url); } return; } const { repo, remote } = found; try { if (repo.currentBookmark?.name !== req.bookmark) { const ok = await vscode.window.showInformationMessage( `Oxygen: the conflict is on ${req.bookmark}; this checkout is on ${repo.currentBookmark?.name ?? "another bookmark"}. Switch?`, { modal: true }, "Switch", ); if (!ok) { return; } await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${req.bookmark}` }, () => repo.oxy.bookmarkSwitch(repo.root, req.bookmark), ); } await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: pulling ${req.bookmark}` }, () => repo.oxy.pull(repo.root, remote), ); await repo.refresh(); await vscode.commands.executeCommand("vscode.open", vscode.Uri.joinPath(vscode.Uri.file(repo.root), req.path)); } catch (e) { showError(e); } } /** The open checkout whose remote is `url`, with that remote's name. */ async function checkoutOf(url: string): Promise<{ repo: Repository; remote: string } | undefined> { for (const repo of repositories.values()) { const remotes = await repo.oxy.remotes(repo.root).catch(() => []); const r = remotes.find((x) => sameRemote(x.url, url)); if (r) { return { repo, remote: r.name }; } } return undefined; } /** * vscode://oxygenhub.oxygen-vscode/plan?… from OxygenHub's swimlanes: squash * or rebase a change of a proposal's bookmark here, where it is signed as * you, then push the rewritten bookmark. */ async function applyPlan(uri: vscode.Uri): Promise { const plan = parsePlanQuery(uri.query); if (typeof plan === "string") { vscode.window.showErrorMessage(`Oxygen: cannot apply this plan: ${plan}.`); return; } const found = await checkoutOf(plan.url); if (!found) { const pick = await vscode.window.showInformationMessage(`Oxygen: no open folder is a checkout of ${plan.url}. Clone it, open it, then follow the link again.`, "Clone…"); if (pick) { await cloneRepository(plan.url); } return; } const { repo, remote } = found; const [c, t] = [plan.change.slice(0, 12), plan.target.slice(0, 12)]; const what = plan.kind === "squash" ? `Squash #${c} into #${t}` : `Rebase #${c} onto #${t}`; const ok = await vscode.window.showInformationMessage(`Oxygen: ${what} on ${plan.bookmark}? The rewritten changes are signed as you.`, { modal: true }, "Apply"); if (!ok) { return; } try { if (repo.currentBookmark?.name !== plan.bookmark) { await repo.oxy.bookmarkSwitch(repo.root, plan.bookmark); } await repo.oxy.pull(repo.root, remote); await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: what }, () => plan.kind === "squash" ? repo.oxy.squash(repo.root, `#${plan.change}`, `#${plan.target}`) : repo.oxy.rebase(repo.root, `#${plan.change}`, [`#${plan.target}`]), ); await repo.refresh(); const push = await vscode.window.showInformationMessage(`Oxygen: ${what} done. Push ${plan.bookmark}? Its history changed, so this replaces it on ${remote}.`, "Push"); if (push) { await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: pushing ${plan.bookmark}` }, () => repo.oxy.push(repo.root, remote, plan.bookmark, true), ); } } catch (e) { showError(e); } } /** Make a signing identity (oxy identity create): a composite key in your * user key directory, and the author every repository then signs with. */ async function createIdentity(): Promise { const name = await vscode.window.showInputBox({ prompt: "Your name, as changes will show it", ignoreFocusOut: true, validateInput: (v) => (v.trim() ? undefined : "A name") }); if (!name) { return; } const email = await vscode.window.showInputBox({ prompt: "Your email", ignoreFocusOut: true, validateInput: (v) => (/.+@.+/.test(v.trim()) ? undefined : "An email address") }); if (!email) { return; } const home = process.env.HOME ?? process.cwd(); const r = await oxy.run(home, ["--plain", "identity", "create", "--name", name.trim(), "--email", email.trim()]); if (r.code === 0) { vscode.window.showInformationMessage(`Oxygen: your identity is ready: ${name.trim()} <${email.trim()}>. Changes you commit and push are signed with it.`); for (const repo of repositories.values()) { void repo.refresh(); } } else { vscode.window.showErrorMessage(`Oxygen: could not create an identity: ${r.stderr.trim() || r.stdout.toString("utf8").trim()}`); } } async function cloneRepository(prefill?: string): Promise { const url = await vscode.window.showInputBox({ prompt: "Repository to clone (path or https URL)", placeHolder: "https://host:8890/project", value: prefill }); if (!url?.trim()) { return; } const parent = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Clone into this folder" }); if (!parent?.[0]) { return; } const name = url.trim().replace(/\/+$/, "").split("/").pop()?.replace(/\.oxy$/, "") || "repository"; const dest = vscode.Uri.joinPath(parent[0], name); try { await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: cloning ${name}` }, () => oxy.clone(parent[0].fsPath, url.trim(), dest.fsPath)); const pick = await vscode.window.showInformationMessage(`Oxygen: cloned into ${dest.fsPath}.`, "Open", "Open in new window"); if (pick) { await vscode.commands.executeCommand("vscode.openFolder", dest, { forceNewWindow: pick === "Open in new window" }); } } catch (e) { showError(e); } } // ---------------------------------------------------------------- history async function showChange(root: string, hash: string): Promise { const repo = repositories.get(root); if (!repo) { return; } let info; try { info = await repo.oxy.show(repo.root, `#${hash.slice(0, 12)}`); } catch (e) { showError(e); return; } const subject = info.message.split("\n")[0]; type Item = vscode.QuickPickItem & { action: string; path?: string }; const items: Item[] = [ { label: "$(diff) Show diff", description: `${info.files.length} file${info.files.length === 1 ? "" : "s"}`, action: "diff" }, { label: "$(arrow-right) Check out this state", description: "the change and its dependencies", action: "checkout" }, { label: "$(edit) Describe…", description: "change the message", action: "describe" }, { label: "$(tag) Tag…", action: "tag" }, { label: "$(trash) Abandon", description: "drop the change; its content stays on disk if the working copy sat on it", action: "abandon" }, ...historyItems(info), { label: "$(copy) Copy ChangeId", action: "copy" }, ...info.files.map((f) => ({ label: `$(file) ${f.path}`, description: `${f.kind}${f.from ? ` from ${f.from}` : ""}`, action: "file", path: f.path })), ]; const pick = await vscode.window.showQuickPick(items, { title: `${info.change_id.slice(0, 8)} ${subject}`, 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"}`, matchOnDescription: true, }); if (!pick) { return; } try { switch (pick.action) { case "diff": case "file": { const text = await repo.oxy.diffText(repo.root, `#${hash.slice(0, 12)}`); const body = `# ${info.change_id.slice(0, 8)} ${subject}\n# ${info.author} · ${new Date(info.when_ms).toLocaleString()}\n\n${text}`; const doc = await vscode.workspace.openTextDocument({ content: body, language: "diff" }); await vscode.window.showTextDocument(doc, { preview: true }); return; } case "checkout": await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`]); break; case "describe": { const message = await vscode.window.showInputBox({ prompt: "New message", value: info.message }); if (!message?.trim()) { return; } await repo.oxy.describe(repo.root, `#${hash.slice(0, 12)}`, message.trim()); break; } case "tag": { const name = await vscode.window.showInputBox({ prompt: "Tag name" }); if (!name?.trim()) { return; } await repo.oxy.tagCreate(repo.root, name.trim(), [`#${hash.slice(0, 12)}`]); break; } case "abandon": { const confirm = await vscode.window.showWarningMessage(`Abandon "${subject}"?`, { modal: true }, "Abandon"); if (confirm !== "Abandon") { return; } const out = await repo.oxy.abandon(repo.root, `#${hash.slice(0, 12)}`); vscode.window.setStatusBarMessage(`$(trash) ${out.split("\n")[0]}`, 5000); break; } case "copy": await vscode.env.clipboard.writeText(info.change_id); return; case "split": case "squash": case "rebase": await historyAction(repo, info, pick.action); return; } await repo.refresh(); } catch (e) { if (e instanceof OxyError && e.message.includes("has changes")) { const confirm = await vscode.window.showWarningMessage("The working copy has uncommitted changes. Discard them and check out?", { modal: true }, "Discard and check out"); if (confirm) { try { await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`], true); await repo.refresh(); } catch (e2) { showError(e2); } } return; } showError(e); } } // ------------------------------------------------ history rewriting (V6) type HistoryAction = "split" | "squash" | "rebase"; /** The rewriting actions a change offers: split for a head with several files, squash when it has dependencies, rebase always. */ function historyItems(info: ShowInfo): (vscode.QuickPickItem & { action: string })[] { const items: (vscode.QuickPickItem & { action: string })[] = []; if (info.head && info.files.length > 1) { items.push({ label: "$(split-horizontal) Split…", description: "chosen files become a separate first change", action: "split" }); } if (info.deps.length > 0) { items.push({ label: "$(fold) Squash into a dependency…", description: "fold this change into one it depends on", action: "squash" }); } items.push({ label: "$(git-pull-request-go-to-changes) Rebase onto…", description: "re-express against another state", action: "rebase" }); return items; } /** Run `action` on a change: choose, confirm, run, then offer Undo (oxy undo reverses every rewrite). */ async function historyAction(repo: Repository, info: ShowInfo, action: HistoryAction): Promise { const change = `#${info.hash.slice(0, 12)}`; const subject = info.message.split("\n")[0] || "(no description)"; let run: () => Promise; let question: string; if (action === "split") { if (!info.head) { void vscode.window.showInformationMessage("Oxygen: only a head can be split; nothing may depend on it yet."); return; } const picked = await vscode.window.showQuickPick( info.files.map((f) => ({ label: f.path, description: f.kind, picked: false })), { title: `Split ${info.change_id.slice(0, 8)}: the files you choose become the first change`, canPickMany: true }, ); if (!picked || picked.length === 0) { return; } if (picked.length === info.files.length) { void vscode.window.showInformationMessage("Oxygen: leave at least one file for the second change."); return; } const paths = picked.map((p) => p.label); question = `Split "${subject}"? ${paths.length} file${paths.length === 1 ? "" : "s"} (${paths.join(", ")}) become a separate change; the rest stay.`; run = () => repo.oxy.split(repo.root, change, paths); } else if (action === "squash") { if (info.deps.length === 0) { void vscode.window.showInformationMessage("Oxygen: this change has no dependency to fold into."); return; } const log = await repo.oxy.log(repo.root, 500); const byHash = new Map(log.changes.map((c) => [c.hash, c])); const choices = info.deps.map((d) => { const c = byHash.get(d); return { label: c ? c.message.split("\n")[0] || "(no description)" : d.slice(0, 12), description: c ? c.change_id.slice(0, 8) : "", hash: d }; }); const into = choices.length === 1 ? choices[0] : await vscode.window.showQuickPick(choices, { title: `Squash ${info.change_id.slice(0, 8)} into…` }); if (!into) { return; } question = `Squash "${subject}" into "${into.label}"? The dependency keeps its ChangeId and absorbs these edits.`; run = () => repo.oxy.squash(repo.root, change, `#${into.hash.slice(0, 12)}`); } else { const bookmarks = await repo.oxy.bookmarks(repo.root); type Onto = vscode.QuickPickItem & { onto?: string }; const choices: Onto[] = [ ...bookmarks.map((b) => ({ label: `$(bookmark) ${b.name}`, description: b.current ? "current bookmark" : "", onto: b.name })), { label: "$(edit) Another change, bookmark or tag…" }, ]; const pick = await vscode.window.showQuickPick(choices, { title: `Rebase ${info.change_id.slice(0, 8)} onto…` }); if (!pick) { return; } let onto = pick.onto; if (!onto) { onto = (await vscode.window.showInputBox({ prompt: "ChangeId prefix, #hash prefix, bookmark or tag" }))?.trim(); if (!onto) { return; } } const target = onto; question = `Rebase "${subject}" onto ${target}? The change is re-expressed against that state; its ChangeId stays.`; run = () => repo.oxy.rebase(repo.root, change, [target]); } const verb = action[0].toUpperCase() + action.slice(1); if ((await vscode.window.showWarningMessage(question, { modal: true, detail: "Undo reverses it (Oxygen: Undo, or oxy undo)." }, verb)) !== verb) { return; } try { const out = await run(); await repo.refresh(); HistoryPanel.refresh(repo.root); const undo = await vscode.window.showInformationMessage(`Oxygen: ${out.split("\n")[0].replace(/^\* /, "")}`, "Undo"); if (undo === "Undo") { await repo.oxy.undo(repo.root); await repo.refresh(); HistoryPanel.refresh(repo.root); } } catch (e) { showError(e); } } /** A history-graph context menu command: the webview passes `{ root, hash }`. */ async function historyContext(ctx: { root?: string; hash?: string } | undefined, action: HistoryAction): Promise { const repo = ctx?.root ? repositories.get(ctx.root) : undefined; if (!repo || !ctx?.hash) { return; } try { const info = await repo.oxy.show(repo.root, `#${ctx.hash.slice(0, 12)}`); await historyAction(repo, info, action); } catch (e) { showError(e); } } // ------------------------------------------------------ commit box (V7) interface BoxState { amend: boolean; resolution: boolean; signing: string; checkedMs: number; } const boxes = new Map(); let extensionContext: vscode.ExtensionContext | undefined; function box(repo: Repository): BoxState { let b = boxes.get(repo.root); if (!b) { b = { amend: false, resolution: false, signing: "", checkedMs: 0 }; boxes.set(repo.root, b); } return b; } /** Placeholder and the Ctrl+Enter action follow amend mode, resolution and signing. */ function updateBox(repo: Repository): void { const b = box(repo); repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: b.amend ? "Amend" : b.resolution ? "Commit resolution" : "Commit", arguments: [repo] }; repo.sourceControl.inputBox.placeholder = placeholder(b); } /** Re-read how commits will be signed, at most every 30 seconds. */ async function refreshSigning(repo: Repository, force = false): Promise { const b = box(repo); if (!force && Date.now() - b.checkedMs < 30_000) { return; } b.checkedMs = Date.now(); try { b.signing = signingLabel(await repo.oxy.identity(repo.root)); } catch { b.signing = ""; } updateBox(repo); } function setAmend(repo: Repository, on: boolean): void { box(repo).amend = on; void vscode.commands.executeCommand("setContext", "oxygen.amending", [...boxes.values()].some((b) => b.amend)); updateBox(repo); } async function toggleAmend(arg: Repository | vscode.SourceControl | undefined, on: boolean): Promise { const repo = repositoryFor(arg); if (!repo) { return; } setAmend(repo, on); if (on && !repo.sourceControl.inputBox.value.trim()) { // Start from the message being amended. try { const last = (await repo.oxy.log(repo.root, 2)).changes.find((c) => !c.working_copy); if (last) { repo.sourceControl.inputBox.value = last.message; } } catch { // An empty box keeps the message anyway. } } } function historyOf(repo: Repository): MessageHistory { return new MessageHistory(extensionContext?.workspaceState.get(`oxygen.messages:${repo.root}`) ?? []); } function remember(repo: Repository, message: string): void { const h = historyOf(repo); h.add(message); void extensionContext?.workspaceState.update(`oxygen.messages:${repo.root}`, h.entries); } async function messageHistory(arg: Repository | vscode.SourceControl | undefined): Promise { const repo = repositoryFor(arg); if (!repo) { return; } const entries = historyOf(repo).entries; if (entries.length === 0) { void vscode.window.showInformationMessage("Oxygen: no messages yet; they are remembered as you commit."); return; } const pick = await vscode.window.showQuickPick( entries.map((m) => ({ label: m.split("\n")[0], detail: m.includes("\n") ? m.split("\n").slice(1).join(" ").trim().slice(0, 160) : undefined, message: m })), { title: "Previous commit messages", placeHolder: "Put one back in the commit box", matchOnDetail: true }, ); if (pick) { repo.sourceControl.inputBox.value = pick.message; } } /** * Warn before committing files that still hold conflict markers: those oxy * reports (markers left in a conflicted file) and any text file whose lines * open or close a conflict. `paths` limits the check to what is committed. */ async function markersOk(repo: Repository, paths?: string[]): Promise { const status = repo.status; if (!status) { return true; } const found: { path: string; line?: number }[] = []; for (const p of status.paths) { if (p.status === "Removed" || (paths && !paths.includes(p.path))) { continue; } if (p.status === "ConflictEdited") { found.push({ path: p.path }); continue; } try { const file = join(repo.root, p.path); if ((await stat(file)).size > 4 * 1024 * 1024) { continue; } const text = (await readFile(file)).toString("utf8"); const lines = conflictMarkerLines(text); if (lines.length > 0) { found.push({ path: p.path, line: lines[0] }); } } catch { // Unreadable or gone: nothing to check. } } if (found.length === 0) { return true; } const list = found.slice(0, 5).map((f) => (f.line ? `${f.path} (line ${f.line})` : f.path)).join(", ") + (found.length > 5 ? ` and ${found.length - 5} more` : ""); const pick = await vscode.window.showWarningMessage( `Conflict markers remain in ${list}. Commit anyway?`, { modal: true, detail: "Committing records the marker lines as ordinary text." }, "Commit anyway", `Open ${basename(found[0].path)}`, ); if (pick?.startsWith("Open ")) { const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(join(repo.root, found[0].path))); const line = Math.max(0, (found[0].line ?? 1) - 1); await vscode.window.showTextDocument(doc, { selection: new vscode.Range(line, 0, line, 0) }); return false; } return pick === "Commit anyway"; } async function fileHistory(uri: vscode.Uri | undefined): Promise { if (!uri) { return; } const repo = [...repositories.values()].find((r) => r.relativePath(uri) !== undefined); const rel = repo?.relativePath(uri); if (!repo || !rel) { void vscode.window.showInformationMessage("Oxygen: this file is not in an Oxygen repository."); return; } try { const log = await repo.oxy.log(repo.root, 200, rel); const changes = log.changes.filter((c) => !c.working_copy); if (changes.length === 0) { void vscode.window.showInformationMessage(`Oxygen: no committed change touches ${rel}.`); return; } const pick = await vscode.window.showQuickPick( changes.map((c, i) => ({ label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`, description: `${c.change_id.slice(0, 8)} · ${c.author} · ${timeAgo(c.when_ms)}`, change: c, previous: changes[i + 1], })), { placeHolder: `${changes.length} change${changes.length === 1 ? "" : "s"} touched ${rel}; pick one to compare with the version before it`, matchOnDescription: true }, ); if (!pick) { return; } const after = oxyUriFor(repo.root, rel, pick.change.hash); if (pick.previous) { const before = oxyUriFor(repo.root, rel, pick.previous.hash); await vscode.commands.executeCommand("vscode.diff", before, after, `${basename(rel)} (${pick.previous.change_id.slice(0, 8)} ↔ ${pick.change.change_id.slice(0, 8)})`); } else { await vscode.commands.executeCommand("vscode.open", after); } } catch (e) { showError(e); } } function oxyUriFor(root: string, rel: string, hash: string): vscode.Uri { return oxyUri(root, rel, `#${hash.slice(0, 12)}`); } // ---------------------------------------------------------------- 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()); }