OXYGENHUB
◆ v0.10.2 oxygenhub-vscode / src / references.ts
5.1 KiB · last changed #N and @user completion, and hovers on #N (0.4.0) 1 month ago source blame history raw
f902d5b8 Andy McCall ↶ 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
}