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