OXYGENHUB

#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