OXYGENHUB

Line links: Open on Remote, Copy Remote Link and Copy Permalink from the gutter and the editor context menu, for a line or the selection, with permalinks pinned to the bookmark's state; 0.10.0 1f7fc1e3ae4a

closes #3
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 16:12 UTC
Signature
✔ signed by Andy McCall
Hash
1f7fc1e3ae4a1a993a20be977e826210317d3985d8cca2e5868286656ccf04f8
Depends on
44bfd5adfc9a 9020cf612749 b4626290e660 bed495a96408 f6bda77ecb9f
Depended on by
dac6876e4d08 013fefef11e3 f532c4266f21 cdcffe39dd9f
In bookmarks
main
In tags
v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
Edits
23 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +8 −0
1 1
# Changelog
2 2
3 +
## 0.10.0
4 +
5 +
- **Line links:** right-click a line number, or inside the editor, for Open on Remote, Copy Remote Link and Copy Permalink.
6 +
  - The link covers the selection, or the line you clicked.
7 +
  - A permalink points at the bookmark's current state, so it keeps showing these lines after later changes.
8 +
  - If the file has uncommitted edits, a note says the lines may not match. Remote links also mention unpushed changes.
9 +
- Open on Remote in the editor title menu now includes the selected lines, not just the cursor line.
10 +
3 11
## 0.9.0
4 12
5 13
- **Amend mode** (the pencil in the Source Control title bar): Ctrl+Enter amends the last change, and the box starts from its message.
…
56 unchanged lines
modified README.md +3 −0
…
39 unchanged lines
40 40
- **Commit box**: amend mode, previous messages, a note of whether the next
41 41
  commit is signed, sealed at push or unsigned, and a warning before
42 42
  committing leftover conflict markers.
43 +
- **Links**: Open on Remote, Copy Remote Link and Copy Permalink from the
44 +
  line-number gutter and the editor context menu, for a line or the
45 +
  selection; permalinks pin the bookmark's current state.
43 46
- **Views**: Oxygen Operations (undo to any point), Oxygen Bisect
44 47
  (start, good, bad, skip, run, reset), Oxygen Trust (policy, signatures),
45 48
  and a History graph with per-change actions (diff, check out, describe,
…
15 unchanged lines
modified package.json +51 −2
…
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.9.0",
5 +
  "version": "0.10.0",
6 6
  "publisher": "oxygen",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygen.png",
…
319 unchanged lines
328 328
        "title": "Previous Commit Messages\u2026",
329 329
        "category": "Oxygen",
330 330
        "icon": "$(history)"
331 +
      },
332 +
      {
333 +
        "command": "oxygen.copyRemoteLink",
334 +
        "title": "Copy Remote Link",
335 +
        "category": "Oxygen",
336 +
        "icon": "$(link)"
337 +
      },
338 +
      {
339 +
        "command": "oxygen.copyPermalink",
340 +
        "title": "Copy Permalink",
341 +
        "category": "Oxygen",
342 +
        "icon": "$(pinned)"
331 343
      }
332 344
    ],
333 345
    "views": {
…
183 unchanged lines
517 529
          "command": "oxygen.fileHistory",
518 530
          "group": "oxygen@2",
519 531
          "when": "oxygen.hasRepository && resourceScheme == file"
532 +
        },
533 +
        {
534 +
          "command": "oxygen.openOnRemote",
535 +
          "when": "oxygen.hasRemoteProvider && resourceScheme == file",
536 +
          "group": "9_oxygen@1"
537 +
        },
538 +
        {
539 +
          "command": "oxygen.copyRemoteLink",
540 +
          "when": "oxygen.hasRemoteProvider && resourceScheme == file",
541 +
          "group": "9_oxygen@2"
542 +
        },
543 +
        {
544 +
          "command": "oxygen.copyPermalink",
545 +
          "when": "oxygen.hasRemoteProvider && resourceScheme == file",
546 +
          "group": "9_oxygen@3"
520 547
        }
521 548
      ],
522 549
      "explorer/context": [
…
159 unchanged lines
682 709
        {
683 710
          "command": "oxygen.openOnRemote",
684 711
          "when": "oxygen.hasRemoteProvider",
685 -
          "group": "oxygen"
712 +
          "group": "oxygen@1"
713 +
        },
714 +
        {
715 +
          "command": "oxygen.copyPermalink",
716 +
          "when": "oxygen.hasRemoteProvider",
717 +
          "group": "oxygen@2"
686 718
        }
687 719
      ],
688 720
      "webview/context": [
…
10 unchanged lines
699 731
        {
700 732
          "command": "oxygen.history.rebase",
701 733
          "when": "webviewId == 'oxygen.history' && webviewSection == 'change'",
734 +
          "group": "oxygen@3"
735 +
        }
736 +
      ],
737 +
      "editor/lineNumber/context": [
738 +
        {
739 +
          "command": "oxygen.openOnRemote",
740 +
          "when": "oxygen.hasRemoteProvider && resourceScheme == file",
741 +
          "group": "oxygen@1"
742 +
        },
743 +
        {
744 +
          "command": "oxygen.copyRemoteLink",
745 +
          "when": "oxygen.hasRemoteProvider && resourceScheme == file",
746 +
          "group": "oxygen@2"
747 +
        },
748 +
        {
749 +
          "command": "oxygen.copyPermalink",
750 +
          "when": "oxygen.hasRemoteProvider && resourceScheme == file",
702 751
          "group": "oxygen@3"
703 752
        }
704 753
      ]
…
59 unchanged lines
modified src/api.ts +30 −1
…
22 unchanged lines
23 23
  | { kind: "repository" }
24 24
  | { kind: "bookmark"; name: string }
25 25
  | { kind: "change"; hash: string }
26 -
  | { kind: "file"; reference: string; path: string; line?: number }
26 +
  | { kind: "file"; reference: string; path: string; line?: number; endLine?: number }
27 27
  | { kind: "proposals" }
28 28
  | { kind: "issues" };
29 29
…
50 unchanged lines
80 80
    }
81 81
  }
82 82
  return best;
83 +
}
84 +
85 +
/**
86 +
 * A state address for permalinks, as OxygenHub writes it: the state's head
87 +
 * hashes, sorted and joined with `+`. Undefined without heads.
88 +
 */
89 +
export function stateSpec(heads: readonly string[]): string | undefined {
90 +
  const hex = [...new Set(heads.map((h) => h.toLowerCase()))].filter((h) => /^[0-9a-f]{64}$/.test(h)).sort();
91 +
  return hex.length > 0 && hex.length === new Set(heads).size ? hex.join("+") : undefined;
92 +
}
93 +
94 +
/**
95 +
 * The 1-based lines to link: the selection when the clicked line is inside it
96 +
 * (or nothing was clicked), otherwise the clicked line alone. `selection` is
97 +
 * 1-based and inclusive; a selection ending at column 0 of a line does not
98 +
 * include that line.
99 +
 */
100 +
export function linkedLines(clicked: number | undefined, selection: { start: number; end: number; endColumn: number; empty: boolean }): { line: number; endLine?: number } {
101 +
  let end = selection.end;
102 +
  if (!selection.empty && selection.endColumn === 0 && end > selection.start) {
103 +
    end -= 1;
104 +
  }
105 +
  if (clicked !== undefined && (selection.empty || clicked < selection.start || clicked > end)) {
106 +
    return { line: clicked };
107 +
  }
108 +
  if (selection.empty || end === selection.start) {
109 +
    return { line: selection.start };
110 +
  }
111 +
  return { line: selection.start, endLine: end };
83 112
}
modified src/extension.ts +68 −9
…
1 unchanged lines
2 2
// decorations, the content provider, and the commands (spec §3).
3 3
4 4
import * as vscode from "vscode";
5 -
import { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, pickProvider, repositoryFor as rootFor } from "./api";
5 +
import { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, linkedLines, pickProvider, repositoryFor as rootFor, stateSpec } from "./api";
6 6
import { basename, join } from "node:path";
7 7
import { Oxy, OxyError, ShowInfo, findBinary } from "./oxy";
8 8
import { Repository, OxyResourceState, SubrepoState } from "./repository";
…
60 unchanged lines
69 69
      await Promise.all([...repositories.values()].map((r) => r.refresh()));
70 70
    }),
71 71
    vscode.commands.registerCommand("oxygen.showOutput", () => output.show()),
72 -
    vscode.commands.registerCommand("oxygen.openOnRemote", (arg?: Repository | vscode.SourceControl) => openOnRemote(undefined, arg instanceof Repository ? arg : undefined)),
72 +
    vscode.commands.registerCommand("oxygen.openOnRemote", (arg?: Repository | vscode.SourceControl | LinkArg) =>
73 +
      openOnRemote(undefined, arg instanceof Repository ? arg : undefined, "open", arg instanceof Repository || isSourceControl(arg) ? undefined : (arg as LinkArg)),
74 +
    ),
75 +
    vscode.commands.registerCommand("oxygen.copyRemoteLink", (arg?: LinkArg) => openOnRemote(undefined, undefined, "copy", arg)),
76 +
    vscode.commands.registerCommand("oxygen.copyPermalink", (arg?: LinkArg) => openOnRemote(undefined, undefined, "permalink", arg)),
73 77
    vscode.commands.registerCommand("oxygen.openChangeOnRemote", (root: string, hash: string) => openOnRemote({ kind: "change", hash }, repositories.get(root))),
74 78
    vscode.commands.registerCommand("oxygen.openFile", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
75 79
    vscode.commands.registerCommand("oxygen.openChanges", (state?: OxyResourceState) => state && openChanges(state)),
…
131 unchanged lines
207 211
    remoteProviders: () => [...remoteProviders],
208 212
  };
209 213
}
214 +
215 +
/** What a remote-link command does with the URL. */
216 +
type LinkMode = "open" | "copy" | "permalink";
210 217
211 -
/** Open the current repository, file or change on whichever provider knows its remote. */
212 -
async function openOnRemote(target?: RemoteTarget, repoArg?: Repository): Promise<void> {
218 +
/** The editor gutter passes `{ lineNumber, uri }`; an editor title menu passes the file's Uri. */
219 +
type LinkArg = { lineNumber?: number; uri?: vscode.Uri } | vscode.Uri | undefined;
220 +
221 +
/**
222 +
 * Open or copy a link to the current repository, file or change on
223 +
 * whichever provider knows its remote. For a file, the lines are the
224 +
 * gutter's clicked line or the selection; `permalink` pins the link to the
225 +
 * bookmark's current state (its heads), so it keeps pointing at these lines.
226 +
 */
227 +
async function openOnRemote(target?: RemoteTarget, repoArg?: Repository, mode: LinkMode = "open", arg?: LinkArg): Promise<void> {
228 +
  const argUri = arg instanceof vscode.Uri ? arg : arg?.uri;
229 +
  const clicked = arg && !(arg instanceof vscode.Uri) ? arg.lineNumber : undefined;
213 230
  const editor = vscode.window.activeTextEditor;
214 -
  const repo = repoArg ?? (editor ? repositoryFor2(editor.document.uri) : undefined) ?? [...repositories.values()][0];
231 +
  const docUri = argUri ?? editor?.document.uri;
232 +
  const repo = repoArg ?? (docUri ? repositoryFor2(docUri) : undefined) ?? [...repositories.values()][0];
215 233
  if (!repo) {
216 234
    void vscode.window.showInformationMessage("Oxygen: no repository is open.");
217 235
    return;
…
5 unchanged lines
223 241
    return;
224 242
  }
225 243
  let t: RemoteTarget = target ?? { kind: "repository" };
226 -
  if (!target && editor) {
227 -
    const rel = repo.relativePath(editor.document.uri);
244 +
  let note = "";
245 +
  if (!target && docUri) {
246 +
    const rel = repo.relativePath(docUri);
228 247
    if (rel) {
229 -
      t = { kind: "file", reference: repo.currentBookmark?.name ?? "main", path: rel, line: editor.selection.active.line + 1 };
248 +
      const bookmark = repo.currentBookmark;
249 +
      let reference = bookmark?.name ?? "main";
250 +
      if (mode === "permalink") {
251 +
        const spec = stateSpec(bookmark?.heads ?? []);
252 +
        if (!spec) {
253 +
          void vscode.window.showInformationMessage("Oxygen: this bookmark has no committed state to link to yet.");
254 +
          return;
255 +
        }
256 +
        reference = spec;
257 +
      }
258 +
      const sameDoc = editor && editor.document.uri.toString() === docUri.toString();
259 +
      const sel = sameDoc
260 +
        ? { start: editor.selection.start.line + 1, end: editor.selection.end.line + 1, endColumn: editor.selection.end.character, empty: editor.selection.isEmpty }
261 +
        : { start: clicked ?? 1, end: clicked ?? 1, endColumn: 0, empty: true };
262 +
      const lines = clicked !== undefined || sameDoc ? linkedLines(clicked, sel) : {};
263 +
      t = { kind: "file", reference, path: rel, ...lines };
264 +
      // Lines are counted in the editor; say so when they may not match the remote.
265 +
      const edited = repo.status?.paths.some((p) => p.path === rel);
266 +
      if (edited || (sameDoc && editor.document.isDirty)) {
267 +
        note = " The file has uncommitted edits, so the lines may differ from the remote's.";
268 +
      } else if (mode !== "permalink" && bookmark && bookmark.ahead !== null && bookmark.ahead > 0) {
269 +
        note = ` ${bookmark.name} has unpushed changes; the remote may not show them yet.`;
270 +
      }
230 271
    }
231 272
  }
232 273
  const url = pick.provider.urlFor(pick.remote.url, t);
233 -
  if (url) {
274 +
  if (!url) {
275 +
    return;
276 +
  }
277 +
  if (mode === "open") {
234 278
    await vscode.env.openExternal(vscode.Uri.parse(url));
279 +
    if (note) {
280 +
      void vscode.window.showInformationMessage(`Oxygen:${note}`);
281 +
    }
282 +
    return;
235 283
  }
284 +
  await vscode.env.clipboard.writeText(url);
285 +
  const what = mode === "permalink" ? "Permalink" : "Link";
286 +
  if (note) {
287 +
    void vscode.window.showWarningMessage(`Oxygen: ${what} copied.${note}`);
288 +
  } else {
289 +
    vscode.window.setStatusBarMessage(`$(link) ${what} copied`, 4000);
290 +
  }
291 +
}
292 +
293 +
function isSourceControl(arg: unknown): arg is vscode.SourceControl {
294 +
  return typeof arg === "object" && arg !== null && "inputBox" in arg && "rootUri" in arg;
236 295
}
237 296
238 297
function repositoryFor2(uri: vscode.Uri): Repository | undefined {
…
1182 unchanged lines
modified test/api.test.mjs +23 −0
…
20 unchanged lines
21 21
  assert.equal(repositoryFor(roots, "/w/a/x.rs"), "/w/a");
22 22
  assert.equal(repositoryFor(roots, "/w/ab/x"), undefined);
23 23
});
24 +
25 +
test("permalink state addresses match OxygenHub's", async () => {
26 +
  const { stateSpec } = await import("../out/api.mjs");
27 +
  const a = "b".repeat(64), b = "a".repeat(64);
28 +
  assert.equal(stateSpec([a, b, a.toUpperCase()]), undefined, "mixed-case duplicates are not a state");
29 +
  assert.equal(stateSpec([a, b]), `${b}+${a}`, "sorted and joined with +");
30 +
  assert.equal(stateSpec([a]), a);
31 +
  assert.equal(stateSpec([]), undefined);
32 +
  assert.equal(stateSpec(["abc"]), undefined, "full hashes only");
33 +
});
34 +
35 +
test("linked lines follow the click and the selection", async () => {
36 +
  const { linkedLines } = await import("../out/api.mjs");
37 +
  const none = { start: 5, end: 5, endColumn: 3, empty: true };
38 +
  assert.deepEqual(linkedLines(undefined, none), { line: 5 });
39 +
  assert.deepEqual(linkedLines(12, none), { line: 12 }, "gutter click without a selection");
40 +
  const sel = { start: 10, end: 20, endColumn: 4, empty: false };
41 +
  assert.deepEqual(linkedLines(undefined, sel), { line: 10, endLine: 20 });
42 +
  assert.deepEqual(linkedLines(15, sel), { line: 10, endLine: 20 }, "a click inside the selection keeps it");
43 +
  assert.deepEqual(linkedLines(30, sel), { line: 30 }, "a click outside wins");
44 +
  assert.deepEqual(linkedLines(undefined, { start: 10, end: 21, endColumn: 0, empty: false }), { line: 10, endLine: 20 }, "a selection ending at column 0 stops the line before");
45 +
  assert.deepEqual(linkedLines(undefined, { start: 7, end: 7, endColumn: 9, empty: false }), { line: 7 });
46 +
});