#N and @user completion, and hovers on #N (0.4.0) f902d5b8bfeb
In the Oxygen commit box and in comment editors, # offers the repository's issues and proposals by number and title, open ones first, and @ offers the people who opened or are assigned to them. Hovering a #N shows its kind, state, title and author with a link to the hub. The commit box's repository comes from its source control's root. Spec §6.2.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 07:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
f902d5b8bfeb39c9a8921b8ed783eb26dbdf9665b94d3ad9d4b1a0c40e01d593- Depends on
- b2dd082c57e4 bf73998c5198 f0b06deac4b9
- Depended on by
- 991516a107fa d21a85954a34 263de209e5ba e04950748142 1cc0ff44e5ca 95f69b4a3ac2
- In bookmarks
- main
- In tags
- v0.10.0 v0.10.1 v0.10.2 v0.11.0 v0.11.1 v0.11.2 v0.13.0
- Edits
- 13 atoms, 3 tree operations, 8 files
Browse the tree at this change
added src/references.ts +135 −0
| 1 | + | // `#N` and `@user` completion, and hover on `#N` (spec §6.2), in the |
|
| 2 | + | // Oxygen commit box and in review comments. The numbers are the |
|
| 3 | + | // repository's issues and proposals (one number space, as on the hub); |
|
| 4 | + | // the users are the people who opened or are assigned to them. |
|
| 5 | + | ||
| 6 | + | import * as vscode from "vscode"; |
|
| 7 | + | import type * as api from "./hubapi"; |
|
| 8 | + | import { HubAuth } from "./auth"; |
|
| 9 | + | import type { HubRepo } from "./proposals"; |
|
| 10 | + | import { numberAt, rootFromScmQuery, typingAt } from "./refs"; |
|
| 11 | + | ||
| 12 | + | interface Ref { |
|
| 13 | + | number: number; |
|
| 14 | + | kind: "issue" | "proposal"; |
|
| 15 | + | title: string; |
|
| 16 | + | state: string; |
|
| 17 | + | author: string; |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | interface Index { |
|
| 21 | + | at: number; |
|
| 22 | + | refs: Map<number, Ref>; |
|
| 23 | + | users: Set<string>; |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /** How long an index is reused before the hub is asked again. */ |
|
| 27 | + | const FRESH_MS = 60_000; |
|
| 28 | + | ||
| 29 | + | /** Where references are typed: the SCM commit box and comment editors. */ |
|
| 30 | + | export const SELECTOR: vscode.DocumentSelector = [{ scheme: "vscode-scm" }, { scheme: "comment" }]; |
|
| 31 | + | ||
| 32 | + | export class ReferenceIndex { |
|
| 33 | + | private readonly cache = new Map<string, Index>(); |
|
| 34 | + | ||
| 35 | + | constructor( |
|
| 36 | + | private readonly auth: HubAuth, |
|
| 37 | + | private readonly repos: () => Promise<HubRepo[]>, |
|
| 38 | + | ) {} |
|
| 39 | + | ||
| 40 | + | clear(): void { |
|
| 41 | + | this.cache.clear(); |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | /** The repository a document types into: the SCM box's root, else the only hub repository. */ |
|
| 45 | + | async repoFor(doc: vscode.TextDocument): Promise<HubRepo | undefined> { |
|
| 46 | + | const repos = await this.repos(); |
|
| 47 | + | const root = doc.uri.scheme === "vscode-scm" ? rootFromScmQuery(doc.uri.query) : undefined; |
|
| 48 | + | if (root) { |
|
| 49 | + | return repos.find((r) => r.handle.root === root); |
|
| 50 | + | } |
|
| 51 | + | return repos.length === 1 ? repos[0] : undefined; |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | async indexOf(repo: HubRepo): Promise<Index> { |
|
| 55 | + | const key = `${repo.base}/${repo.owner}/${repo.name}`; |
|
| 56 | + | const hit = this.cache.get(key); |
|
| 57 | + | if (hit && Date.now() - hit.at < FRESH_MS) { |
|
| 58 | + | return hit; |
|
| 59 | + | } |
|
| 60 | + | const client = this.auth.clientFor(repo.base); |
|
| 61 | + | const [issues, proposals] = await Promise.all([client.issues(repo.owner, repo.name, "all").catch(() => [] as api.Issue[]), client.proposals(repo.owner, repo.name).catch(() => [] as api.Proposal[])]); |
|
| 62 | + | const refs = new Map<number, Ref>(); |
|
| 63 | + | const users = new Set<string>(); |
|
| 64 | + | for (const i of issues) { |
|
| 65 | + | refs.set(i.number, { number: i.number, kind: "issue", title: i.title, state: i.state, author: i.author }); |
|
| 66 | + | users.add(i.author); |
|
| 67 | + | i.assignees.forEach((a) => users.add(a)); |
|
| 68 | + | } |
|
| 69 | + | for (const p of proposals) { |
|
| 70 | + | refs.set(p.number, { number: p.number, kind: "proposal", title: p.title, state: p.state, author: p.author }); |
|
| 71 | + | users.add(p.author); |
|
| 72 | + | } |
|
| 73 | + | const me = this.auth.sessionFor(repo.base)?.username; |
|
| 74 | + | if (me) { |
|
| 75 | + | users.add(me); |
|
| 76 | + | } |
|
| 77 | + | users.delete(""); |
|
| 78 | + | const index = { at: Date.now(), refs, users }; |
|
| 79 | + | this.cache.set(key, index); |
|
| 80 | + | return index; |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | export class ReferenceCompletions implements vscode.CompletionItemProvider { |
|
| 85 | + | constructor(private readonly index: ReferenceIndex) {} |
|
| 86 | + | ||
| 87 | + | async provideCompletionItems(doc: vscode.TextDocument, pos: vscode.Position): Promise<vscode.CompletionItem[]> { |
|
| 88 | + | const before = doc.lineAt(pos.line).text.slice(0, pos.character); |
|
| 89 | + | const typing = typingAt(before); |
|
| 90 | + | const repo = typing && (await this.index.repoFor(doc)); |
|
| 91 | + | if (!typing || !repo) { |
|
| 92 | + | return []; |
|
| 93 | + | } |
|
| 94 | + | const idx = await this.index.indexOf(repo); |
|
| 95 | + | const range = new vscode.Range(pos.line, typing.start, pos.line, pos.character); |
|
| 96 | + | if (typing.kind === "number") { |
|
| 97 | + | // Open items first, then newest. |
|
| 98 | + | const refs = [...idx.refs.values()].sort((a, b) => Number(a.state !== "open") - Number(b.state !== "open") || b.number - a.number); |
|
| 99 | + | return refs.map((r, i) => { |
|
| 100 | + | const item = new vscode.CompletionItem({ label: `#${r.number}`, description: r.title }, r.kind === "issue" ? vscode.CompletionItemKind.Issue : vscode.CompletionItemKind.Reference); |
|
| 101 | + | item.detail = `${r.kind} · ${r.state} · ${r.author}`; |
|
| 102 | + | item.filterText = `#${r.number} ${r.title}`; |
|
| 103 | + | item.sortText = String(i).padStart(6, "0"); |
|
| 104 | + | item.insertText = `#${r.number}`; |
|
| 105 | + | item.range = range; |
|
| 106 | + | return item; |
|
| 107 | + | }); |
|
| 108 | + | } |
|
| 109 | + | return [...idx.users].sort().map((u) => { |
|
| 110 | + | const item = new vscode.CompletionItem(`@${u}`, vscode.CompletionItemKind.User); |
|
| 111 | + | item.insertText = `@${u}`; |
|
| 112 | + | item.range = range; |
|
| 113 | + | return item; |
|
| 114 | + | }); |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | export class ReferenceHovers implements vscode.HoverProvider { |
|
| 119 | + | constructor(private readonly index: ReferenceIndex) {} |
|
| 120 | + | ||
| 121 | + | async provideHover(doc: vscode.TextDocument, pos: vscode.Position): Promise<vscode.Hover | undefined> { |
|
| 122 | + | const at = numberAt(doc.lineAt(pos.line).text, pos.character); |
|
| 123 | + | const repo = at && (await this.index.repoFor(doc)); |
|
| 124 | + | if (!at || !repo) { |
|
| 125 | + | return undefined; |
|
| 126 | + | } |
|
| 127 | + | const r = (await this.index.indexOf(repo)).refs.get(at.number); |
|
| 128 | + | if (!r) { |
|
| 129 | + | return undefined; |
|
| 130 | + | } |
|
| 131 | + | const url = `${repo.base}/${repo.owner}/${repo.name}/${r.kind}/${r.number}`; |
|
| 132 | + | const md = new vscode.MarkdownString(`**${r.kind === "issue" ? "Issue" : "Proposal"} #${r.number}** · ${r.state}\n\n${r.title}\n\n_${r.author}_ · [open on the hub](${url})`); |
|
| 133 | + | return new vscode.Hover(md, new vscode.Range(pos.line, at.start, pos.line, at.end)); |
|
| 134 | + | } |
|
| 135 | + | } |
added src/refs.ts +49 −0
| 1 | + | // `#N` and `@user` in text the user is typing (spec §6.2): what is being |
|
| 2 | + | // completed at the cursor, and which reference a position sits on. No |
|
| 3 | + | // vscode imports, so it is tested with plain Node. |
|
| 4 | + | ||
| 5 | + | /** A reference being typed: `#12` (issue or proposal) or `@an` (a user). */ |
|
| 6 | + | export type Typing = { kind: "number"; prefix: string; start: number } | { kind: "user"; prefix: string; start: number }; |
|
| 7 | + | ||
| 8 | + | /** |
|
| 9 | + | * What the text before the cursor is in the middle of typing, if anything. |
|
| 10 | + | * A reference starts a word: after the line start, whitespace or an |
|
| 11 | + | * opening bracket, so `a#1` and `mail@host` are not references. |
|
| 12 | + | */ |
|
| 13 | + | export function typingAt(before: string): Typing | undefined { |
|
| 14 | + | const n = /(^|[\s([{])#(\d*)$/.exec(before); |
|
| 15 | + | if (n) { |
|
| 16 | + | return { kind: "number", prefix: n[2], start: before.length - n[2].length - 1 }; |
|
| 17 | + | } |
|
| 18 | + | const u = /(^|[\s([{])@([A-Za-z0-9][A-Za-z0-9_-]*|)$/.exec(before); |
|
| 19 | + | if (u) { |
|
| 20 | + | return { kind: "user", prefix: u[2], start: before.length - u[2].length - 1 }; |
|
| 21 | + | } |
|
| 22 | + | return undefined; |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /** The `#N` reference covering `column` of `line`, with its span. */ |
|
| 26 | + | export function numberAt(line: string, column: number): { number: number; start: number; end: number } | undefined { |
|
| 27 | + | const re = /(^|[\s([{])#(\d+)\b/g; |
|
| 28 | + | for (let m = re.exec(line); m; m = re.exec(line)) { |
|
| 29 | + | const start = m.index + m[1].length; |
|
| 30 | + | const end = start + 1 + m[2].length; |
|
| 31 | + | if (column >= start && column <= end) { |
|
| 32 | + | return { number: Number(m[2]), start, end }; |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | return undefined; |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | /** A repository root named by an SCM input box's URI query (`rootUri=file:///…`). */ |
|
| 39 | + | export function rootFromScmQuery(query: string): string | undefined { |
|
| 40 | + | const raw = new URLSearchParams(query).get("rootUri"); |
|
| 41 | + | if (!raw) { |
|
| 42 | + | return undefined; |
|
| 43 | + | } |
|
| 44 | + | try { |
|
| 45 | + | return decodeURIComponent(new URL(raw).pathname); |
|
| 46 | + | } catch { |
|
| 47 | + | return undefined; |
|
| 48 | + | } |
|
| 49 | + | } |
added test/refs.test.mjs +31 −0
| 1 | + | // Parsing #N and @user as they are typed and hovered. |
|
| 2 | + | import { test } from "node:test"; |
|
| 3 | + | import assert from "node:assert/strict"; |
|
| 4 | + | import { typingAt, numberAt, rootFromScmQuery } from "../out/refs.mjs"; |
|
| 5 | + | ||
| 6 | + | test("what is being typed", () => { |
|
| 7 | + | assert.deepEqual(typingAt("Fixes #"), { kind: "number", prefix: "", start: 6 }); |
|
| 8 | + | assert.deepEqual(typingAt("Fixes #1"), { kind: "number", prefix: "1", start: 6 }); |
|
| 9 | + | assert.deepEqual(typingAt("#12"), { kind: "number", prefix: "12", start: 0 }); |
|
| 10 | + | assert.deepEqual(typingAt("(see #4"), { kind: "number", prefix: "4", start: 5 }); |
|
| 11 | + | assert.deepEqual(typingAt("cc @an"), { kind: "user", prefix: "an", start: 3 }); |
|
| 12 | + | assert.deepEqual(typingAt("@"), { kind: "user", prefix: "", start: 0 }); |
|
| 13 | + | assert.equal(typingAt("a#1"), undefined, "not at a word start"); |
|
| 14 | + | assert.equal(typingAt("mail@host"), undefined, "an address is not a mention"); |
|
| 15 | + | assert.equal(typingAt("#1 done"), undefined, "the cursor has moved on"); |
|
| 16 | + | }); |
|
| 17 | + | ||
| 18 | + | test("which reference a position sits on", () => { |
|
| 19 | + | const line = "Fixes #12 and (#7), not a#3"; |
|
| 20 | + | assert.deepEqual(numberAt(line, 7), { number: 12, start: 6, end: 9 }); |
|
| 21 | + | assert.deepEqual(numberAt(line, 9), { number: 12, start: 6, end: 9 }); |
|
| 22 | + | assert.deepEqual(numberAt(line, 16), { number: 7, start: 15, end: 17 }); |
|
| 23 | + | assert.equal(numberAt(line, 2), undefined); |
|
| 24 | + | assert.equal(numberAt(line, 26), undefined, "a#3 is not a reference"); |
|
| 25 | + | }); |
|
| 26 | + | ||
| 27 | + | test("the SCM box names its repository", () => { |
|
| 28 | + | assert.equal(rootFromScmQuery("rootUri=" + encodeURIComponent("file:///home/andy/development/oxygen")), "/home/andy/development/oxygen"); |
|
| 29 | + | assert.equal(rootFromScmQuery("rootUri=" + encodeURIComponent("file:///home/a%20b/x")), "/home/a b/x"); |
|
| 30 | + | assert.equal(rootFromScmQuery(""), undefined); |
|
| 31 | + | }); |
modified CHANGELOG.md +5 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.4.0 |
|
| 4 | + | ||
| 5 | + | - Type `#` in the Oxygen commit box or a review comment to pick an issue or proposal by number or title, and `@` to mention someone who has opened or been assigned one. |
|
| 6 | + | - Hover a `#N` to see what it is: kind, state, title and author, with a link to the hub. |
|
| 7 | + | ||
| 3 | 8 | ## 0.3.0 |
|
| 4 | 9 | ||
| 5 | 10 | - A bell in the status bar with your unread notifications across every hub you are signed in to, and a message when new ones arrive (`oxygenhub.announceNotifications`). |
|
| … | 29 unchanged lines |
modified README.md +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | ||
| 8 | 8 | - **Sign in** with a hub URL and a personal access token (Settings › Tokens on the hub). |
|
| 9 | 9 | - **Proposals** view: needing me, mine, open; detail with blockers, approvals, checks and changes; check out the projected state; review; seal your approval and land, both through `oxy` in a terminal with your own key. |
|
| 10 | + | - **References while typing**: `#` and `@` completion in the commit box and comments, and a hover on `#N`. |
|
| 10 | 11 | - **Notifications**: an unread count in the status bar and an inbox that opens proposals and issues in the editor. |
|
| 11 | 12 | - **Diffs in the editor**: expand a proposal to its files and open each in VS Code's diff editor (target against projected state), or View Changes for all of them at once, without checking out. |
|
| 12 | 13 | - **Review threads** in those diffs and on the checked-out proposal's files, anchored to atoms so they follow lines through rebases. |
|
| … | 5 unchanged lines |
modified docs/spec.md +4 −0
| … | 66 unchanged lines |
||
| 67 | 67 | ||
| 68 | 68 | In the diff editors (§5.1) the same controller places threads on the projected side only, at the line the hub reports for each anchored comment (the API's `line`, found by blame on the projected state), so no checkout or local `oxy` call is needed. A new thread or reply there posts the file and line; the hub anchors it to that line's atom. |
|
| 69 | 69 | ||
| 70 | + | ### 6.2 References while typing |
|
| 71 | + | ||
| 72 | + | In the Oxygen commit box and in comment editors, `#` completes the repository's issues and proposals (one number space, open ones first, newest first; the title is shown and matched) and `@` completes people: the authors and assignees of those issues and proposals, and the signed-in user. Hovering a `#N` shows its kind, state, title and author with a link to the hub. The commit box's repository is the one its source control names (`rootUri`); a comment editor uses the workspace's hub repository when there is exactly one. The lists are fetched per repository and reused for a minute, and cleared on refresh. |
|
| 73 | + | ||
| 70 | 74 | ## 7. Approvals and landing |
|
| 71 | 75 | ||
| 72 | 76 | **Approve** posts the review, then offers to run `oxy seal --proposal <url>` in the repository's terminal so the approval is backed by the user's seal; the row shows "pending seal" until the hub reports it sealed. **Land** runs `oxy proposal land <url>` in a terminal (the hub's own landing is offered when the hub reports it can land, for repositories without a policy). Both use the integrated terminal so the user sees exactly what runs with their key. |
|
| … | 30 unchanged lines |
modified package.json +2 −2
| … | 1 unchanged lines |
||
| 2 | 2 | "name": "oxygenhub-vscode", |
|
| 3 | 3 | "displayName": "Oxygenhub", |
|
| 4 | 4 | "description": "Proposals, reviews, issues and checks from an Oxygenhub, in the editor. Builds on the Oxygen extension.", |
|
| 5 | - | "version": "0.3.0", |
|
| 5 | + | "version": "0.4.0", |
|
| 6 | 6 | "publisher": "andymccall", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "icon": "assets/images/oxygenhub.png", |
|
| … | 318 unchanged lines |
||
| 327 | 327 | "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap", |
|
| 328 | 328 | "watch": "npm run build -- --watch", |
|
| 329 | 329 | "typecheck": "tsc --noEmit -p .", |
|
| 330 | - | "pretest": "esbuild src/hub.ts --bundle --platform=node --format=esm --outfile=out/hub.mjs && esbuild src/provider.ts --bundle --platform=node --format=esm --outfile=out/provider.mjs", |
|
| 330 | + | "pretest": "esbuild src/hub.ts --bundle --platform=node --format=esm --outfile=out/hub.mjs && esbuild src/provider.ts --bundle --platform=node --format=esm --outfile=out/provider.mjs && esbuild src/refs.ts --bundle --platform=node --format=esm --outfile=out/refs.mjs", |
|
| 331 | 331 | "test": "node --test \"test/**/*.test.mjs\"", |
|
| 332 | 332 | "check": "npm run typecheck && npm run build && npm test", |
|
| 333 | 333 | "package": "vsce package --no-dependencies" |
|
| … | 9 unchanged lines |
modified src/extension.ts +8 −0
| … | 6 unchanged lines |
||
| 7 | 7 | import { ProposalContentProvider, SCHEME, openAllChanges, openFileDiff, refOf } from "./diff"; |
|
| 8 | 8 | import { hubUrl } from "./hub"; |
|
| 9 | 9 | import { InboxBar } from "./inbox"; |
|
| 10 | + | import { ReferenceCompletions, ReferenceHovers, ReferenceIndex, SELECTOR } from "./references"; |
|
| 10 | 11 | import { IssueNode, IssuesProvider, showIssue } from "./issues"; |
|
| 11 | 12 | import type { OxygenAPI } from "./oxygenapi"; |
|
| 12 | 13 | import { HubRepo, ProposalFileNode, ProposalNode, ProposalsProvider, showDetail } from "./proposals"; |
|
| … | 8 unchanged lines |
||
| 21 | 22 | let bar: ChecksBar; |
|
| 22 | 23 | let content: ProposalContentProvider; |
|
| 23 | 24 | let inbox: InboxBar; |
|
| 25 | + | let references: ReferenceIndex; |
|
| 24 | 26 | ||
| 25 | 27 | export async function activate(context: vscode.ExtensionContext): Promise<void> { |
|
| 26 | 28 | auth = new HubAuth(context.secrets); |
|
| … | 28 unchanged lines |
||
| 55 | 57 | return true; |
|
| 56 | 58 | }); |
|
| 57 | 59 | context.subscriptions.push(inbox); |
|
| 60 | + | references = new ReferenceIndex(auth, () => proposals.repos()); |
|
| 61 | + | context.subscriptions.push( |
|
| 62 | + | vscode.languages.registerCompletionItemProvider(SELECTOR, new ReferenceCompletions(references), "#", "@"), |
|
| 63 | + | vscode.languages.registerHoverProvider(SELECTOR, new ReferenceHovers(references)), |
|
| 64 | + | ); |
|
| 58 | 65 | content = new ProposalContentProvider(auth); |
|
| 59 | 66 | context.subscriptions.push(content, vscode.workspace.registerTextDocumentContentProvider(SCHEME, content)); |
|
| 60 | 67 | context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.proposals", proposals), vscode.window.registerTreeDataProvider("oxygenhub.issues", issues), review, bar); |
|
| … | 85 unchanged lines |
||
| 146 | 153 | ||
| 147 | 154 | function refreshAll(): void { |
|
| 148 | 155 | proposals.refresh(); |
|
| 156 | + | references.clear(); |
|
| 149 | 157 | refreshOpenDiffs(); |
|
| 150 | 158 | issues.refresh(); |
|
| 151 | 159 | void bar.refresh(); |
|
| … | 181 unchanged lines |