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 | }); |