OXYGENHUB

Apply swimlane plans from OxygenHub (0.12.0) 196392053c9f

vscode://oxygen.oxygen-vscode/plan links find the checkout, confirm, switch
and pull the proposal's bookmark, squash or rebase, then offer to push.

closes #6
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 07:11 UTC
Signature
✔ signed by Andy McCall
Hash
196392053c9f742e9981f5fb9da6cfcb90963b8dcf0a551fee71f4e1e3885706
Depends on
25103dddd993 bed495a96408 cdcffe39dd9f f532c4266f21
Depended on by
dac6876e4d08 3701d06abc9b 26410312ce16 813df1a92fbb 0c26846a9a81
In bookmarks
main
In tags
v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
Edits
12 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +8 −0
1 1
# Changelog
2 2
3 +
## 0.12.0 (2026-09-29)
4 +
5 +
- **Plans from the swimlanes:** on a proposal's swimlane board in OxygenHub, drag one change onto another to plan a squash or a rebase. **Apply in VS Code** opens `vscode://oxygen.oxygen-vscode/plan?url=…&bookmark=…&kind=…&change=…&target=…`.
6 +
  - The extension finds the checkout of that repository, or offers to clone it.
7 +
  - It confirms the plan, switches to the proposal's bookmark and pulls it, then squashes or rebases. The rewritten changes are signed with your key.
8 +
  - It then offers to push the bookmark, replacing it on the hub.
9 +
  - Links with a bad URL, bookmark, kind or change hash are refused.
10 +
3 11
## 0.11.0 (2026-09-29)
4 12
5 13
- **Resolve in VS Code:** OxygenHub's conflict resolver links to a conflict with `vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…`.
…
71 unchanged lines
modified README.md +4 −0
…
50 unchanged lines
51 51
52 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 53
54 +
### Squash or rebase from a proposal's swimlanes
55 +
56 +
On a proposal's swimlane board in OxygenHub, drag a change onto another to plan a squash (into it) or a rebase (onto it). **Apply in VS Code** opens this extension. It confirms the plan, switches to the proposal's bookmark, pulls it, and rewrites the changes here, where they are signed with your key. It then offers to push the bookmark.
57 +
54 58
### Sync with a remote
55 59
56 60
- The status bar's sync counter shows changes to pull and to push. Click it to **Sync**.
…
71 unchanged lines
modified package.json +1 −1
…
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.11.0",
5 +
  "version": "0.12.0",
6 6
  "publisher": "oxygen",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygen.png",
…
805 unchanged lines
modified src/extension.ts +64 −1
…
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 +
import { parsePlanQuery, parseResolveQuery, sameRemote } from "./handoff";
19 19
20 20
const MIN_VERSION = { format: 2, protocol: 1 };
21 21
…
726 unchanged lines
748 748
 * open the file, where the conflict lenses take over.
749 749
 */
750 750
async function handleUri(uri: vscode.Uri): Promise<void> {
751 +
  if (uri.path === "/plan") {
752 +
    return applyPlan(uri);
753 +
  }
751 754
  if (uri.path !== "/resolve") {
752 755
    return;
753 756
  }
…
41 unchanged lines
795 798
    );
796 799
    await repo.refresh();
797 800
    await vscode.commands.executeCommand("vscode.open", vscode.Uri.joinPath(vscode.Uri.file(repo.root), req.path));
801 +
  } catch (e) {
802 +
    showError(e);
803 +
  }
804 +
}
805 +
806 +
/** The open checkout whose remote is `url`, with that remote's name. */
807 +
async function checkoutOf(url: string): Promise<{ repo: Repository; remote: string } | undefined> {
808 +
  for (const repo of repositories.values()) {
809 +
    const remotes = await repo.oxy.remotes(repo.root).catch(() => []);
810 +
    const r = remotes.find((x) => sameRemote(x.url, url));
811 +
    if (r) {
812 +
      return { repo, remote: r.name };
813 +
    }
814 +
  }
815 +
  return undefined;
816 +
}
817 +
818 +
/**
819 +
 * vscode://oxygen.oxygen-vscode/plan?… from OxygenHub's swimlanes: squash
820 +
 * or rebase a change of a proposal's bookmark here, where it is signed as
821 +
 * you, then push the rewritten bookmark.
822 +
 */
823 +
async function applyPlan(uri: vscode.Uri): Promise<void> {
824 +
  const plan = parsePlanQuery(uri.query);
825 +
  if (typeof plan === "string") {
826 +
    vscode.window.showErrorMessage(`Oxygen: cannot apply this plan: ${plan}.`);
827 +
    return;
828 +
  }
829 +
  const found = await checkoutOf(plan.url);
830 +
  if (!found) {
831 +
    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…");
832 +
    if (pick) {
833 +
      await cloneRepository(plan.url);
834 +
    }
835 +
    return;
836 +
  }
837 +
  const { repo, remote } = found;
838 +
  const [c, t] = [plan.change.slice(0, 12), plan.target.slice(0, 12)];
839 +
  const what = plan.kind === "squash" ? `Squash #${c} into #${t}` : `Rebase #${c} onto #${t}`;
840 +
  const ok = await vscode.window.showInformationMessage(`Oxygen: ${what} on ${plan.bookmark}? The rewritten changes are signed as you.`, { modal: true }, "Apply");
841 +
  if (!ok) {
842 +
    return;
843 +
  }
844 +
  try {
845 +
    if (repo.currentBookmark?.name !== plan.bookmark) {
846 +
      await repo.oxy.bookmarkSwitch(repo.root, plan.bookmark);
847 +
    }
848 +
    await repo.oxy.pull(repo.root, remote);
849 +
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: what }, () =>
850 +
      plan.kind === "squash"
851 +
        ? repo.oxy.squash(repo.root, `#${plan.change}`, `#${plan.target}`)
852 +
        : repo.oxy.rebase(repo.root, `#${plan.change}`, [`#${plan.target}`]),
853 +
    );
854 +
    await repo.refresh();
855 +
    const push = await vscode.window.showInformationMessage(`Oxygen: ${what} done. Push ${plan.bookmark}? Its history changed, so this replaces it on ${remote}.`, "Push");
856 +
    if (push) {
857 +
      await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: pushing ${plan.bookmark}` }, () =>
858 +
        repo.oxy.push(repo.root, remote, plan.bookmark, true),
859 +
      );
860 +
    }
798 861
  } catch (e) {
799 862
    showError(e);
800 863
  }
…
740 unchanged lines
modified src/handoff.ts +32 −0
…
39 unchanged lines
40 40
  };
41 41
  return norm(a) === norm(b);
42 42
}
43 +
44 +
export interface PlanRequest {
45 +
  url: string;
46 +
  bookmark: string;
47 +
  /** `squash` (change into target) or `rebase` (change onto target). */
48 +
  kind: "squash" | "rebase";
49 +
  change: string;
50 +
  target: string;
51 +
}
52 +
53 +
/**
54 +
 * A rewrite planned on OxygenHub's swimlanes:
55 +
 * vscode://oxygen.oxygen-vscode/plan?url=…&bookmark=…&kind=…&change=…&target=…
56 +
 */
57 +
export function parsePlanQuery(query: string): PlanRequest | string {
58 +
  const q = new URLSearchParams(query);
59 +
  const base = parseResolveQuery(new URLSearchParams({ url: q.get("url") ?? "", bookmark: q.get("bookmark") ?? "", path: "x" }).toString());
60 +
  if (typeof base === "string") {
61 +
    return base;
62 +
  }
63 +
  const kind = q.get("kind");
64 +
  if (kind !== "squash" && kind !== "rebase") {
65 +
    return "the link names no squash or rebase";
66 +
  }
67 +
  const hex = /^[0-9a-f]{64}$/;
68 +
  const change = q.get("change") ?? "";
69 +
  const target = q.get("target") ?? "";
70 +
  if (!hex.test(change) || !hex.test(target) || change === target) {
71 +
    return "the link names no two changes";
72 +
  }
73 +
  return { url: base.url, bookmark: base.bookmark, kind, change, target };
74 +
}
modified test/handoff.test.mjs +11 −1
1 1
import { test } from "node:test";
2 2
import assert from "node:assert/strict";
3 -
import { parseResolveQuery, sameRemote } from "../out/handoff.mjs";
3 +
import { parsePlanQuery, parseResolveQuery, sameRemote } from "../out/handoff.mjs";
4 4
5 5
test("resolve links are read and checked", () => {
6 6
  const q = new URLSearchParams({ url: "https://hub.example:7343/andy/demo", bookmark: "feature/x", path: "src/a.txt" }).toString();
…
8 unchanged lines
15 15
  assert.ok(sameRemote("https://Hub.Example:7343/andy/demo/", "https://hub.example:7343/andy/demo"));
16 16
  assert.ok(!sameRemote("https://hub.example:7343/andy/demo", "https://hub.example:7343/andy/other"));
17 17
  assert.ok(!sameRemote("https://hub.example:7343/andy/demo", "http://hub.example:7343/andy/demo"));
18 +
});
19 +
20 +
test("plan links are read and checked", () => {
21 +
  const c = "a".repeat(64), t = "b".repeat(64);
22 +
  const q = new URLSearchParams({ url: "https://hub.example:7343/andy/demo", bookmark: "feature", kind: "squash", change: c, target: t }).toString();
23 +
  assert.deepEqual(parsePlanQuery(q), { url: "https://hub.example:7343/andy/demo", bookmark: "feature", kind: "squash", change: c, target: t });
24 +
  assert.match(parsePlanQuery(q.replace("squash", "reorder")), /no squash or rebase/);
25 +
  assert.match(parsePlanQuery(q.replace(t, c)), /no two changes/);
26 +
  assert.match(parsePlanQuery(q.replace(c, "abc")), /no two changes/);
27 +
  assert.match(parsePlanQuery(q.replace("feature", "a..b")), /no valid bookmark/);
18 28
});