| 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 |
} |