import type { WebviewToExtension } from './types' /** 注释里指向仓库代码的链接:从仓库根起的路径,可选一个符号。 */ export interface RepoLink { /** 从仓库根算起,用 / 分隔,开头没有 / 或 ./ */ path: string /** 类名、函数名,或「类名.成员名」。指向目录或整个文件时没有 */ symbol?: string } const SCHEME = /^[a-z][a-z0-9+.-]*:/i const IDENT = /^[\p{L}_$][\p{L}\p{N}_$]*$/u /** * 从链接地址拆出仓库路径和可选符号。 * 不是这种写法就没有:http / mailto、行号(`#123`)、带 `..`、绝对路径、盘符、以 `/` 或 `./` 开头。 */ export function parseRepoLink(url: string): RepoLink | undefined { let text: string try { text = decodeURIComponent(url.trim()) } catch { return undefined } if (text === '' || SCHEME.test(text) || text.startsWith('/') || text.startsWith('./') || text.startsWith('../') || text.includes('\\') || hasControl(text)) return undefined const hash = text.indexOf('#') const rawPath = hash === -1 ? text : text.slice(0, hash) const symbol = hash === -1 ? undefined : text.slice(hash + 1) const path = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath if (!isRepoPath(path)) return undefined if (symbol === undefined) return { path } if (!isSymbol(symbol)) return undefined return { path, symbol } } function hasControl(text: string): boolean { for (const char of text) { if (char.charCodeAt(0) <= 31) return true } return false } function isRepoPath(path: string): boolean { if (path === '' || path.includes('?')) return false return path.split('/').every(segment => segment !== '' && segment !== '.' && segment !== '..') } function isSymbol(symbol: string): boolean { const parts = symbol.split('.') return (parts.length === 1 || parts.length === 2) && parts.every(part => IDENT.test(part)) } function symbolKey(path: string, symbol: string): string { return `${path}#${symbol}` } /** * 页面上的仓库链接:点了发给扩展端打开;预览出来后把路径发去问哪些文件没有; * 扩展端说没有的,标成坏的。符号在不在要点了才知道。 */ export class RepoLinks { private request = 0 /** 最近一次问过的路径,没变就不再问 */ private sent = '' private missingPaths = new Set() private missingSymbols = new Set() constructor(private readonly post: (msg: WebviewToExtension) => void) {} /** 点在仓库链接上:发给扩展端打开,返回 true。不是仓库链接就不管。 */ click(href: string | null): boolean { const link = href === null ? undefined : parseRepoLink(href) if (!link) return false this.post({ type: 'openRepoLink', path: link.path, ...(link.symbol !== undefined && { symbol: link.symbol }) }) return true } /** 页面上现有的仓库链接。路径变了才问扩展端哪些没有。 */ scan(hrefs: readonly string[]): void { const paths = [...new Set(hrefs.flatMap((href) => { const link = parseRepoLink(href) return link ? [link.path] : [] }))].sort() const key = paths.join('\n') if (key === this.sent) return this.sent = key if (paths.length === 0) return this.request += 1 this.post({ type: 'checkRepoLinks', id: this.request, paths }) } /** id 那次询问的回答。不是最近一次的丢掉,返回这次有没有用上。 */ checked(id: number, missing: readonly string[]): boolean { if (id !== this.request) return false this.missingPaths = new Set(missing) return true } /** 扩展端打开时发现文件或符号没有。 */ broken(path: string, symbol?: string): void { if (symbol !== undefined) this.missingSymbols.add(symbolKey(path, symbol)) else this.missingPaths.add(path) } /** 打开成功:这个路径在,这个符号也在。 */ opened(path: string, symbol?: string): void { this.missingPaths.delete(path) if (symbol !== undefined) this.missingSymbols.delete(symbolKey(path, symbol)) } /** 这个地址现在是不是坏的(文件没有,或这个符号没有)。还没问到的不算。 */ isBroken(href: string): boolean { const link = parseRepoLink(href) if (!link) return false if (this.missingPaths.has(link.path)) return true return link.symbol !== undefined && this.missingSymbols.has(symbolKey(link.path, link.symbol)) } }