135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
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<string>()
|
|
private missingSymbols = new Set<string>()
|
|
|
|
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))
|
|
}
|
|
}
|