OXYGENHUB

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