Resolve in VS Code: open a conflict from OxygenHub f532c4266f21
The extension handles vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…, the link OxygenHub's conflict resolver offers: it finds the open checkout whose remote is that repository (or offers to clone it), asks before switching to the conflict's bookmark, pulls it, and opens the file with its conflict lenses. Links naming a bad URL, bookmark or path (.., .oxy) are refused. Parsing and remote matching are in src/handoff.ts, tested. 0.11.0. refs oxygenhub#32
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 05:10 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
f532c4266f21de52a84beccfdb040c25895c63d84a3f070d0a99689f030ff367- Depends on
- 1f7fc1e3ae4a 25103dddd993 44bfd5adfc9a 71d8bf9717d2 bed495a96408 cdcffe39dd9f d2c9a5843004
- Depended on by
- 3701d06abc9b 26410312ce16 813df1a92fbb 013fefef11e3 0c26846a9a81 196392053c9f
- In bookmarks
- main
- In tags
- v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
- Edits
- 12 atoms, 2 tree operations, 6 files
Browse the tree at this change
added src/handoff.ts +42 −0
| 1 | + | // "Resolve in VS Code" (OxygenHub's conflict resolver hands a conflict to |
|
| 2 | + | // the extension): vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=… |
|
| 3 | + | // Parsing and matching live here, apart from VS Code, so they are tested. |
|
| 4 | + | ||
| 5 | + | export interface ResolveRequest { |
|
| 6 | + | /** The repository's clone URL on the hub. */ |
|
| 7 | + | url: string; |
|
| 8 | + | bookmark: string; |
|
| 9 | + | /** Relative to the repository root. */ |
|
| 10 | + | path: string; |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | /** Read a resolve request's query string, or say what is wrong with it. */ |
|
| 14 | + | export function parseResolveQuery(query: string): ResolveRequest | string { |
|
| 15 | + | const q = new URLSearchParams(query); |
|
| 16 | + | const url = (q.get("url") ?? "").trim(); |
|
| 17 | + | const bookmark = (q.get("bookmark") ?? "").trim(); |
|
| 18 | + | const path = (q.get("path") ?? "").trim().replace(/^\/+/, ""); |
|
| 19 | + | if (!/^https?:\/\/[^\s]+$/.test(url)) { |
|
| 20 | + | return "the link names no repository URL"; |
|
| 21 | + | } |
|
| 22 | + | if (!bookmark || /[\s~^:?*[\\]/.test(bookmark) || bookmark.includes("..")) { |
|
| 23 | + | return "the link names no valid bookmark"; |
|
| 24 | + | } |
|
| 25 | + | if (!path || path.split("/").some((c) => c === "" || c === "." || c === ".." || c === ".oxy")) { |
|
| 26 | + | return "the link names no valid path"; |
|
| 27 | + | } |
|
| 28 | + | return { url, bookmark, path }; |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /** Whether two remote URLs name the same repository (case of the host and a trailing slash aside). */ |
|
| 32 | + | export function sameRemote(a: string, b: string): boolean { |
|
| 33 | + | const norm = (u: string) => { |
|
| 34 | + | try { |
|
| 35 | + | const x = new URL(u.trim()); |
|
| 36 | + | return `${x.protocol}//${x.host.toLowerCase()}${x.pathname.replace(/\/+$/, "").replace(/\.oxy$/, "")}`; |
|
| 37 | + | } catch { |
|
| 38 | + | return u.trim().replace(/\/+$/, ""); |
|
| 39 | + | } |
|
| 40 | + | }; |
|
| 41 | + | return norm(a) === norm(b); |
|
| 42 | + | } |
added test/handoff.test.mjs +18 −0
| 1 | + | import { test } from "node:test"; |
|
| 2 | + | import assert from "node:assert/strict"; |
|
| 3 | + | import { parseResolveQuery, sameRemote } from "../out/handoff.mjs"; |
|
| 4 | + | ||
| 5 | + | test("resolve links are read and checked", () => { |
|
| 6 | + | const q = new URLSearchParams({ url: "https://hub.example:7343/andy/demo", bookmark: "feature/x", path: "src/a.txt" }).toString(); |
|
| 7 | + | assert.deepEqual(parseResolveQuery(q), { url: "https://hub.example:7343/andy/demo", bookmark: "feature/x", path: "src/a.txt" }); |
|
| 8 | + | assert.match(parseResolveQuery("bookmark=main&path=a"), /no repository URL/); |
|
| 9 | + | assert.match(parseResolveQuery("url=https://h/r&bookmark=a..b&path=a"), /no valid bookmark/); |
|
| 10 | + | assert.match(parseResolveQuery("url=https://h/r&bookmark=main&path=../etc/passwd"), /no valid path/); |
|
| 11 | + | assert.match(parseResolveQuery("url=https://h/r&bookmark=main&path=.oxy/config"), /no valid path/); |
|
| 12 | + | }); |
|
| 13 | + | ||
| 14 | + | test("remotes match across a trailing slash and the host's case", () => { |
|
| 15 | + | assert.ok(sameRemote("https://Hub.Example:7343/andy/demo/", "https://hub.example:7343/andy/demo")); |
|
| 16 | + | assert.ok(!sameRemote("https://hub.example:7343/andy/demo", "https://hub.example:7343/andy/other")); |
|
| 17 | + | assert.ok(!sameRemote("https://hub.example:7343/andy/demo", "http://hub.example:7343/andy/demo")); |
|
| 18 | + | }); |
modified CHANGELOG.md +7 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.11.0 (2026-09-29) |
|
| 4 | + | ||
| 5 | + | - **Resolve in VS Code:** OxygenHub's conflict resolver links to a conflict with `vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…`. |
|
| 6 | + | - The extension finds the open checkout of that repository, or offers to clone it. |
|
| 7 | + | - It asks before switching to the conflict's bookmark, pulls it, and opens the file with its conflict lenses. |
|
| 8 | + | - Links with a bad URL, bookmark or path (such as `..` or `.oxy`) are refused. |
|
| 9 | + | ||
| 3 | 10 | ## 0.10.0 (2026-09-28) |
|
| 4 | 11 | ||
| 5 | 12 | - **Line links:** right-click a line number, or inside the editor, for Open on Remote, Copy Remote Link and Copy Permalink. |
|
| … | 64 unchanged lines |
modified README.md +2 −0
| … | 48 unchanged lines |
||
| 49 | 49 | 3. For a conflict in the tree, such as two files given the same name, **Keep This Candidate** on the candidate you want. |
|
| 50 | 50 | 4. Choose **Mark Resolved**, then commit. The commit box reads "Describe the resolution". |
|
| 51 | 51 | ||
| 52 | + | OxygenHub's conflict resolver also offers **Resolve in VS Code**. The link opens this extension, which switches your checkout to the conflict's bookmark (after asking), pulls it, and opens the file here. |
|
| 53 | + | ||
| 52 | 54 | ### Sync with a remote |
|
| 53 | 55 | ||
| 54 | 56 | - The status bar's sync counter shows changes to pull and to push. Click it to **Sync**. |
|
| … | 71 unchanged lines |
modified package.json +4 −3
| … | 1 unchanged lines |
||
| 2 | 2 | "name": "oxygen-vscode", |
|
| 3 | 3 | "displayName": "Oxygen", |
|
| 4 | 4 | "description": "Oxygen version control: source control view, diffs, decorations, bookmarks and history for oxy repositories", |
|
| 5 | - | "version": "0.10.0", |
|
| 5 | + | "version": "0.11.0", |
|
| 6 | 6 | "publisher": "oxygen", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "icon": "assets/images/oxygen.png", |
|
| … | 12 unchanged lines |
||
| 21 | 21 | "activationEvents": [ |
|
| 22 | 22 | "workspaceContains:.oxy/format", |
|
| 23 | 23 | "workspaceContains:.oxy", |
|
| 24 | - | "onStartupFinished" |
|
| 24 | + | "onStartupFinished", |
|
| 25 | + | "onUri" |
|
| 25 | 26 | ], |
|
| 26 | 27 | "main": "./dist/extension.js", |
|
| 27 | 28 | "contributes": { |
|
| … | 769 unchanged lines |
||
| 797 | 798 | "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap", |
|
| 798 | 799 | "watch": "npm run build -- --watch", |
|
| 799 | 800 | "typecheck": "tsc --noEmit -p .", |
|
| 800 | - | "pretest": "esbuild src/oxy.ts --bundle --platform=node --format=esm --outfile=out/oxy.mjs && esbuild src/diffutil.ts --bundle --platform=node --format=esm --outfile=out/diffutil.mjs && esbuild src/markers.ts --bundle --platform=node --format=esm --outfile=out/markers.mjs && esbuild src/api.ts --bundle --platform=node --format=esm --outfile=out/api.mjs && esbuild src/commitbox.ts --bundle --platform=node --format=esm --outfile=out/commitbox.mjs", |
|
| 801 | + | "pretest": "esbuild src/oxy.ts --bundle --platform=node --format=esm --outfile=out/oxy.mjs && esbuild src/diffutil.ts --bundle --platform=node --format=esm --outfile=out/diffutil.mjs && esbuild src/markers.ts --bundle --platform=node --format=esm --outfile=out/markers.mjs && esbuild src/api.ts --bundle --platform=node --format=esm --outfile=out/api.mjs && esbuild src/commitbox.ts --bundle --platform=node --format=esm --outfile=out/commitbox.mjs && esbuild src/handoff.ts --bundle --platform=node --format=esm --outfile=out/handoff.mjs", |
|
| 801 | 802 | "test": "node --test \"test/**/*.test.mjs\"", |
|
| 802 | 803 | "check": "npm run typecheck && npm run build && npm test", |
|
| 803 | 804 | "package": "vsce package --no-dependencies" |
|
| … | 9 unchanged lines |
modified src/extension.ts +63 −2
| … | 14 unchanged lines |
||
| 15 | 15 | import { MessageHistory, conflictMarkerLines, placeholder, signingLabel } from "./commitbox"; |
|
| 16 | 16 | import { readFile, stat } from "node:fs/promises"; |
|
| 17 | 17 | import { ConflictCodeLensProvider, acceptSides, keepCandidate, markResolved } from "./conflicts"; |
|
| 18 | + | import { parseResolveQuery, sameRemote } from "./handoff"; |
|
| 18 | 19 | ||
| 19 | 20 | const MIN_VERSION = { format: 2, protocol: 1 }; |
|
| 20 | 21 | ||
| … | 117 unchanged lines |
||
| 138 | 139 | vscode.commands.registerCommand("oxygen.createTag", (arg?: Repository | vscode.SourceControl) => createTag(arg)), |
|
| 139 | 140 | vscode.commands.registerCommand("oxygen.addWorkspace", (arg?: Repository | vscode.SourceControl) => addWorkspace(arg)), |
|
| 140 | 141 | vscode.commands.registerCommand("oxygen.clone", () => cloneRepository()), |
|
| 142 | + | vscode.window.registerUriHandler({ handleUri: (uri) => handleUri(uri) }), |
|
| 141 | 143 | vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()), |
|
| 142 | 144 | vscode.workspace.onDidChangeConfiguration((e) => { |
|
| 143 | 145 | if (e.affectsConfiguration("oxygen.path")) { |
|
| … | 595 unchanged lines |
||
| 739 | 741 | } |
|
| 740 | 742 | } |
|
| 741 | 743 | ||
| 742 | - | async function cloneRepository(): Promise<void> { |
|
| 743 | - | const url = await vscode.window.showInputBox({ prompt: "Repository to clone (path or https URL)", placeHolder: "https://host:8890/project" }); |
|
| 744 | + | /** |
|
| 745 | + | * vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=… from |
|
| 746 | + | * OxygenHub's conflict resolver: find the open repository with that remote |
|
| 747 | + | * (or offer to clone it), switch to the bookmark if asked to, pull it, and |
|
| 748 | + | * open the file, where the conflict lenses take over. |
|
| 749 | + | */ |
|
| 750 | + | async function handleUri(uri: vscode.Uri): Promise<void> { |
|
| 751 | + | if (uri.path !== "/resolve") { |
|
| 752 | + | return; |
|
| 753 | + | } |
|
| 754 | + | const req = parseResolveQuery(uri.query); |
|
| 755 | + | if (typeof req === "string") { |
|
| 756 | + | vscode.window.showErrorMessage(`Oxygen: cannot open this conflict: ${req}.`); |
|
| 757 | + | return; |
|
| 758 | + | } |
|
| 759 | + | let found: { repo: Repository; remote: string } | undefined; |
|
| 760 | + | for (const repo of repositories.values()) { |
|
| 761 | + | const remotes = await repo.oxy.remotes(repo.root).catch(() => []); |
|
| 762 | + | const r = remotes.find((x) => sameRemote(x.url, req.url)); |
|
| 763 | + | if (r) { |
|
| 764 | + | found = { repo, remote: r.name }; |
|
| 765 | + | break; |
|
| 766 | + | } |
|
| 767 | + | } |
|
| 768 | + | if (!found) { |
|
| 769 | + | const pick = await vscode.window.showInformationMessage( |
|
| 770 | + | `Oxygen: no open folder is a checkout of ${req.url}. Clone it, open it, then follow the link again.`, |
|
| 771 | + | "Clone…", |
|
| 772 | + | ); |
|
| 773 | + | if (pick) { |
|
| 774 | + | await cloneRepository(req.url); |
|
| 775 | + | } |
|
| 776 | + | return; |
|
| 777 | + | } |
|
| 778 | + | const { repo, remote } = found; |
|
| 779 | + | try { |
|
| 780 | + | if (repo.currentBookmark?.name !== req.bookmark) { |
|
| 781 | + | const ok = await vscode.window.showInformationMessage( |
|
| 782 | + | `Oxygen: the conflict is on ${req.bookmark}; this checkout is on ${repo.currentBookmark?.name ?? "another bookmark"}. Switch?`, |
|
| 783 | + | { modal: true }, |
|
| 784 | + | "Switch", |
|
| 785 | + | ); |
|
| 786 | + | if (!ok) { |
|
| 787 | + | return; |
|
| 788 | + | } |
|
| 789 | + | await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${req.bookmark}` }, () => |
|
| 790 | + | repo.oxy.bookmarkSwitch(repo.root, req.bookmark), |
|
| 791 | + | ); |
|
| 792 | + | } |
|
| 793 | + | await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: pulling ${req.bookmark}` }, () => |
|
| 794 | + | repo.oxy.pull(repo.root, remote), |
|
| 795 | + | ); |
|
| 796 | + | await repo.refresh(); |
|
| 797 | + | await vscode.commands.executeCommand("vscode.open", vscode.Uri.joinPath(vscode.Uri.file(repo.root), req.path)); |
|
| 798 | + | } catch (e) { |
|
| 799 | + | showError(e); |
|
| 800 | + | } |
|
| 801 | + | } |
|
| 802 | + | ||
| 803 | + | async function cloneRepository(prefill?: string): Promise<void> { |
|
| 804 | + | const url = await vscode.window.showInputBox({ prompt: "Repository to clone (path or https URL)", placeHolder: "https://host:8890/project", value: prefill }); |
|
| 744 | 805 | if (!url?.trim()) { |
|
| 745 | 806 | return; |
|
| 746 | 807 | } |
|
| … | 733 unchanged lines |