✨ feat(vscode): 代码阅读注释里不带路径的引用 [Name] 能点,本文件定义的优先、再找导入的

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 0958506681
commit 300d8a07b9
16 files changed
+600 -124

No files matched your search

@@ -87,7 +87,7 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
</div>
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
{expanded
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer) }} />
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer, 'answer') }} />
: (
<button type="button" className="cr-qa-preview" onClick={toggle}>
{answerPreview(entry.answer)}
@@ -112,7 +112,7 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
<div
className="cr-markdown cr-qa-answer"
data-streaming={running ? '' : undefined}
dangerouslySetInnerHTML={{ __html: running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text) }}
dangerouslySetInnerHTML={{ __html: running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text, 'answer') }}
/>
)}
{running && (
@@ -7,7 +7,7 @@ import { SegmentText } from './SegmentText'
/** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */
export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement {
if ('markdown' in text)
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown) }} />
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown, 'comment') }} />
return (
<p className={cn('cr-code', className)}>
<SegmentText segments={text} />
@@ -81,6 +81,29 @@ describe('renderMarkdown', () => {
})
})
describe('不带路径的引用', () => {
it('注释里的 [Name]、[A.b] 是链接,地址 symbol:名字', () => {
expect(renderMarkdown('见 [LocalRuntimeDisabled]。')).toBe('<p>见 <a href="symbol:LocalRuntimeDisabled">LocalRuntimeDisabled</a>。</p>\n')
expect(renderMarkdown('[Agent.owner_id]')).toBe('<p><a href="symbol:Agent.owner_id">Agent.owner_id</a></p>\n')
expect(renderMarkdown('转写谁说的话: [USER] / [ASSISTANT]')).toBe('<p>转写谁说的话: <a href="symbol:USER">USER</a> / <a href="symbol:ASSISTANT">ASSISTANT</a></p>\n')
expect(renderMarkdown('见[Name]')).toContain('<a href="symbol:Name">')
})
it('反引号里的、下标、中文、后面跟 (…) 的完整链接都不算', () => {
expect(renderMarkdown('`[x]`')).toBe('<p><code>[x]</code></p>\n')
expect(renderMarkdown('items[i] 和 f()[k]、a[0][1]')).toBe('<p>items[i] 和 f()[k]、a[0][1]</p>\n')
expect(renderMarkdown('以 [会话进展] 开头')).toBe('<p>以 [会话进展] 开头</p>\n')
expect(renderMarkdown('[名字](backend/a.py#Agent)')).toBe('<p><a href="backend/a.py#Agent">名字</a></p>\n')
expect(renderMarkdown('[Agent](backend/a.py#Agent)')).toBe('<p><a href="backend/a.py#Agent">Agent</a></p>\n')
expect(renderMarkdown('[Agent](javascript:x)')).not.toContain('<a')
})
it('问 AI 的回答不认;带增删的注释照样认', () => {
expect(renderMarkdown('见 [Agent]', 'answer')).toBe('<p>见 [Agent]</p>\n')
expect(renderMarkdownDiff([{ text: '见 ' }, { text: '[Agent]', change: 'add' }])).toContain('href="symbol:Agent"')
})
})
describe('renderMarkdownDiff', () => {
it('照常排版,新加的字包进 <ins>、删掉的包进 <del>', () => {
expect(renderMarkdownDiff([{ text: '放锁' }, { text: '前', change: 'del' }, { text: '都', change: 'add' }, { text: '必须比对 `token`' }]))
+59 -11
View File
@@ -1,16 +1,56 @@
import type { StateInline } from 'markdown-it'
import MarkdownIt from 'markdown-it'
import { isRepoImageFile, repoImagePlaceholder } from './repoImage'
import { parseRepoLink } from './repoLink'
import { parseRepoLink, symbolHref } from './repoLink'
/**
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名 或 #类名.成员);
* 别的写法照原文显示。webview 会拦下 http 链接的点击交给 VS Code 在外部打开;仓库路径链接由页面另发给扩展端,不走外部打开。
* 图片只认仓库里的图片文件:先显示路径,问到文件在了再按原图比例显示。http / https 图片不显示。
* 注释(不是问 AI 的回答)里 KDoc 写法的不带路径的引用 `[Name]`、`[Class.member]` 也是链接,见 symbolReference。
*/
const md = new MarkdownIt({ html: false, linkify: true })
md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined
/**
* 渲染时带的环境:symbolLinks 为真时认不带路径的引用(注释的 Markdown 才认,问 AI 的回答不认)。
* markdown-it 自己用 env.references 记参考式链接的定义,不能占这个名字。
*/
type RenderEnv = Record<string, unknown> & { symbolLinks?: boolean }
/** 方括号里的标识符(字母、数字、下划线),点分开的几段:`Name`、`Class.member`。 */
const REFERENCE = /^\[([A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*)\]/i
/** 紧跟在这些后面的方括号是下标(`items[i]`、`a[0][1]`、`f()[k]`),不是引用。 */
const SUBSCRIPTED = /[\w$\])]/
/**
* 不带路径的引用:`[Name]`、`[Class.member]` 写成链接,地址是 `symbol:Name`(作者写不出这种地址,validateLink 不放行),
* 点了、坏没坏和仓库路径链接走同一套(RepoLinks)。方括号里得是标识符(中文这类照原样是文字),后面紧跟 `(` 是完整链接、
* 紧跟 `[` 是参考式链接,都不算;紧跟在名字、`]`、`)` 后面的是下标,不算。反引号里的是代码,到不了这里。
*/
function symbolReference(state: StateInline, silent: boolean): boolean {
if (!(state.env as RenderEnv | undefined)?.symbolLinks || state.src.charCodeAt(state.pos) !== 0x5B)
return false
if (state.pos > 0 && SUBSCRIPTED.test(state.src[state.pos - 1]))
return false
const match = REFERENCE.exec(state.src.slice(state.pos))
if (!match)
return false
const next = state.src[state.pos + match[0].length]
if (next === '(' || next === '[')
return false
if (!silent) {
const open = state.push('link_open', 'a', 1)
open.attrSet('href', symbolHref(match[1]))
state.push('text', '', 0).content = match[1]
state.push('link_close', 'a', -1)
}
state.pos += match[0].length
return true
}
md.inline.ruler.after('link', 'symbol_reference', symbolReference)
/** 仓库图片先占位。http / https 和不是图片的仓库路径仍是「!」加链接,不画出图片。 */
md.renderer.rules.image = (tokens, idx) => {
const token = tokens[idx]
@@ -42,16 +82,24 @@ md.core.ruler.push('link_title_tooltip', (state) => {
const CACHE_SIZE = 500
const cache = new Map<string, string>()
/** 还在变的文字(逐字长出来的回答)→ HTML:每次都现渲染,不进缓存,免得半截的文字把缓存挤满。 */
export function renderMarkdownOnce(text: string): string {
return md.render(text)
/** 渲染的是什么:注释(说明、注释块,认不带路径的引用),还是问 AI 的回答。 */
export type MarkdownKind = 'comment' | 'answer'
function envOf(kind: MarkdownKind): RenderEnv {
return { symbolLinks: kind === 'comment' }
}
/** Markdown 原文 → HTML;同一段文字只渲染一次。 */
export function renderMarkdown(text: string): string {
const html = cache.get(text) ?? md.render(text)
cache.delete(text)
cache.set(text, html)
/** 还在变的文字(逐字长出来的回答)→ HTML:每次都现渲染,不进缓存,免得半截的文字把缓存挤满。 */
export function renderMarkdownOnce(text: string): string {
return md.render(text, envOf('answer'))
}
/** Markdown 原文 → HTML;同一段文字(同一种)只渲染一次。 */
export function renderMarkdown(text: string, kind: MarkdownKind = 'comment'): string {
const key = `${kind}\u0000${text}`
const html = cache.get(key) ?? md.render(text, envOf(kind))
cache.delete(key)
cache.set(key, html)
if (cache.size > CACHE_SIZE)
cache.delete(cache.keys().next().value!)
return html
@@ -67,7 +115,7 @@ const MARKS = {
const BLOCK_LEAD = /^\s*(?:(?:[\-*+>]|\d+[.)]|#{1,6})\s+)*/
/**
* 带增删的 Markdown 说明 → HTML:照常排版,新加的字包进 <ins>、删掉的包进 <del>(样式见 .cr-diff-word)。
* 带增删的 Markdown 说明 → HTML:照常排版(同注释,认不带路径的引用),新加的字包进 <ins>、删掉的包进 <del>(样式见 .cr-diff-word)。
* 一截跨了几行时逐行包,记号不跨段落、列表项。
*/
export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add' | 'del' }[]): string {
@@ -85,7 +133,7 @@ export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add
return rest ? `${lead}${open}${rest}${close}` : line
}).join('\n')
}
let html = md.render(text)
let html = md.render(text, envOf('comment'))
for (const [open, close, openTag, closeTag] of Object.values(MARKS))
html = html.replaceAll(open, openTag).replaceAll(close, closeTag)
return html
@@ -1,6 +1,6 @@
import type { WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest'
import { parseRepoLink, RepoLinks } from './repoLink'
import { parseCommentLink, parseRepoLink, RepoLinks, symbolHref } from './repoLink'
describe('parseRepoLink', () => {
it('合法路径', () => {
@@ -47,12 +47,12 @@ describe('parseRepoLink', () => {
})
})
describe('repoLinks', () => {
function setup(): { links: RepoLinks, sent: WebviewToExtension[] } {
const sent: WebviewToExtension[] = []
return { links: new RepoLinks(msg => sent.push(msg)), sent }
}
function setup(): { links: RepoLinks, sent: WebviewToExtension[] } {
const sent: WebviewToExtension[] = []
return { links: new RepoLinks(msg => sent.push(msg)), sent }
}
describe('repoLinks', () => {
it('点仓库链接发给扩展端,http 不管', () => {
const { links, sent } = setup()
expect(links.click('backend/src/luffy_agent/agent/models.py#Agent.owner_id')).toBe(true)
@@ -69,11 +69,11 @@ describe('repoLinks', () => {
const { links, sent } = setup()
links.scan(['vendor/agents', 'https://example.com', 'backend/a.py#Agent'])
links.scan(['backend/a.py#Agent', 'vendor/agents'])
expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py', 'vendor/agents'] }])
expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py', 'vendor/agents'], symbols: [] }])
expect(links.checked(0, ['vendor/agents'])).toBe(false)
expect(links.checked(0, ['vendor/agents'], [])).toBe(false)
expect(links.isBroken('vendor/agents')).toBe(false)
expect(links.checked(1, ['vendor/agents'])).toBe(true)
expect(links.checked(1, ['vendor/agents'], [])).toBe(true)
expect(links.isBroken('vendor/agents')).toBe(true)
expect(links.isBroken('backend/a.py#Agent')).toBe(false)
})
@@ -87,3 +87,43 @@ describe('repoLinks', () => {
expect(links.isBroken('backend/a.py#Agent')).toBe(false)
})
})
describe('不带路径的引用', () => {
it('地址是 symbol:名字,只认标识符和点分的几段;别的按仓库路径链接认', () => {
expect(symbolHref('Agent.owner_id')).toBe('symbol:Agent.owner_id')
expect(parseCommentLink('symbol:LocalRuntimeDisabled')).toEqual({ symbol: 'LocalRuntimeDisabled' })
expect(parseCommentLink('symbol:Agent.owner_id')).toEqual({ symbol: 'Agent.owner_id' })
expect(parseCommentLink('symbol:会话进展')).toBeUndefined()
expect(parseCommentLink('symbol:a b')).toBeUndefined()
expect(parseCommentLink('backend/a.py#Agent')).toEqual({ path: 'backend/a.py', symbol: 'Agent' })
})
it('点了发 openRepoLink(没有 path);和仓库路径一起问,找不到的标坏,打开成功就不再标', () => {
const { links, sent } = setup()
expect(links.click('symbol:LocalRuntimeDisabled')).toBe(true)
expect(sent).toEqual([{ type: 'openRepoLink', symbol: 'LocalRuntimeDisabled' }])
links.scan(['symbol:USER', 'backend/a.py', 'symbol:Missing', 'symbol:USER'])
expect(sent.at(-1)).toEqual({ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py'], symbols: ['Missing', 'USER'] })
expect(links.checked(1, [], ['Missing'])).toBe(true)
expect(links.isBroken('symbol:Missing')).toBe(true)
expect(links.isBroken('symbol:USER')).toBe(false)
links.opened(undefined, 'Missing')
expect(links.isBroken('symbol:Missing')).toBe(false)
links.broken(undefined, 'USER')
expect(links.isBroken('symbol:USER')).toBe(true)
})
it('同样的链接不再问;文档换了版本(stale)再问一次', () => {
const { links, sent } = setup()
links.scan(['symbol:USER'])
links.scan(['symbol:USER'])
expect(sent).toHaveLength(1)
links.stale()
links.scan(['symbol:USER'])
expect(sent).toEqual([
{ type: 'checkRepoLinks', id: 1, paths: [], symbols: ['USER'] },
{ type: 'checkRepoLinks', id: 2, paths: [], symbols: ['USER'] },
])
})
})
+71 -21
View File
@@ -8,8 +8,36 @@ export interface RepoLink {
symbol?: string
}
/**
* 注释里不带路径的引用(`[Name]`、`[Class.member]`):没有路径,扩展端按当前文件找这个符号(本文件定义的,再是导入的)。
* 页面上它是地址为 `symbol:Name` 的链接(见 symbolHref)。
*/
export interface SymbolLink {
path?: undefined
symbol: string
}
/** 注释里能点的链接:仓库路径链接,或不带路径的引用。 */
export type CommentLink = RepoLink | SymbolLink
const SCHEME = /^[a-z][a-z0-9+.-]*:/i
const IDENT = /^[\p{L}_$][\p{L}\p{N}_$]*$/u
/** 不带路径的引用在页面上的地址前缀。作者在注释里写不出这种地址(markdown.ts 的 validateLink 不放行),只有引用规则会生成。 */
const SYMBOL_SCHEME = 'symbol:'
const REFERENCE = /^[A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*$/i
/** 不带路径的引用写成的链接地址:`symbol:Class.member`。 */
export function symbolHref(symbol: string): string {
return `${SYMBOL_SCHEME}${symbol}`
}
/** 页面上一个链接地址是哪种能点的链接:`symbol:` 开头的是不带路径的引用,别的按仓库路径链接认(见 parseRepoLink)。都不是就没有。 */
export function parseCommentLink(href: string): CommentLink | undefined {
if (!href.startsWith(SYMBOL_SCHEME))
return parseRepoLink(href)
const symbol = href.slice(SYMBOL_SCHEME.length)
return REFERENCE.test(symbol) ? { symbol } : undefined
}
/**
* 从链接地址拆出仓库路径和可选符号。
@@ -62,71 +90,93 @@ function symbolKey(path: string, symbol: string): string {
}
/**
* 页面上的仓库链接:点了发给扩展端打开;预览出来后把路径发去问哪些文件没有;
* 扩展端说没有的,标成坏的。符号在不在要点了才知道。
* 页面上的仓库链接和不带路径的引用:点了发给扩展端打开;预览出来后把路径、引用发去问哪些文件没有、哪些引用找不到;
* 扩展端说没有的,标成坏的。仓库链接的符号在不在要点了才知道。
*/
export class RepoLinks {
private request = 0
/** 最近一次问过的路径,没变就不再问 */
/** 最近一次问过的路径和引用,没变就不再问 */
private sent = ''
private missingPaths = new Set<string>()
private missingSymbols = new Set<string>()
/** 找不到的不带路径的引用 */
private unresolved = new Set<string>()
constructor(private readonly post: (msg: WebviewToExtension) => void) {}
/** 点在仓库链接上:发给扩展端打开,返回 true。不是仓库链接就不管。 */
/** 点在仓库链接或不带路径的引用上:发给扩展端打开,返回 true。别的链接不管。 */
click(href: string | null): boolean {
const link = href === null ? undefined : parseRepoLink(href)
const link = href === null ? undefined : parseCommentLink(href)
if (!link)
return false
this.post({ type: 'openRepoLink', path: link.path, ...(link.symbol !== undefined && { symbol: link.symbol }) })
this.post({ type: 'openRepoLink', ...(link.path !== undefined && { path: link.path }), ...(link.symbol !== undefined && { symbol: link.symbol }) })
return true
}
/** 页面上现有的仓库链接。路径变了才问扩展端哪些没有。 */
/** 页面上现有的链接。路径、引用变了(或文档换了版本,见 stale)才问扩展端哪些路径没有、哪些引用找不到。 */
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')
const links = hrefs.flatMap((href) => {
const link = parseCommentLink(href)
return link ? [link] : []
})
const paths = [...new Set(links.flatMap(link => link.path === undefined ? [] : [link.path]))].sort()
const symbols = [...new Set(links.flatMap(link => link.path === undefined ? [link.symbol] : []))].sort()
const key = `${paths.join('\n')}\u0000${symbols.join('\n')}`
if (key === this.sent)
return
this.sent = key
if (paths.length === 0)
if (paths.length === 0 && symbols.length === 0)
return
this.request += 1
this.post({ type: 'checkRepoLinks', id: this.request, paths })
this.post({ type: 'checkRepoLinks', id: this.request, paths, symbols })
}
/** 文档换了版本:引用指到哪跟着源码变(加了定义、改了导入),下次 scan 照样再问。 */
stale(): void {
this.sent = ''
}
/** id 那次询问的回答。不是最近一次的丢掉,返回这次有没有用上。 */
checked(id: number, missing: readonly string[]): boolean {
checked(id: number, missing: readonly string[], unresolved: readonly string[]): boolean {
if (id !== this.request)
return false
this.missingPaths = new Set(missing)
this.unresolved = new Set(unresolved)
return true
}
/** 扩展端打开时发现文件或符号没有。 */
broken(path: string, symbol?: string): void {
/** 扩展端打开时发现文件或符号没有;没有路径的是不带路径的引用找不到。 */
broken(path: string | undefined, symbol?: string): void {
if (path === undefined) {
if (symbol !== undefined)
this.unresolved.add(symbol)
return
}
if (symbol !== undefined)
this.missingSymbols.add(symbolKey(path, symbol))
else
this.missingPaths.add(path)
}
/** 打开成功:这个路径在,这个符号也在。 */
opened(path: string, symbol?: string): void {
/** 打开成功:这个路径在,这个符号也在;没有路径的是不带路径的引用找到了。 */
opened(path: string | undefined, symbol?: string): void {
if (path === undefined) {
if (symbol !== undefined)
this.unresolved.delete(symbol)
return
}
this.missingPaths.delete(path)
if (symbol !== undefined)
this.missingSymbols.delete(symbolKey(path, symbol))
}
/** 这个地址现在是不是坏的(文件没有,或这个符号没有)。还没问到的不算。 */
/** 这个地址现在是不是坏的(文件没有,这个符号没有,或引用找不到)。还没问到的不算。 */
isBroken(href: string): boolean {
const link = parseRepoLink(href)
const link = parseCommentLink(href)
if (!link)
return false
if (link.path === undefined)
return this.unresolved.has(link.symbol)
if (this.missingPaths.has(link.path))
return true
return link.symbol !== undefined && this.missingSymbols.has(symbolKey(link.path, link.symbol))
+11 -10
View File
@@ -411,9 +411,9 @@ export interface DocumentDiff {
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
*/
export type ExtensionToWebview
@@ -431,9 +431,9 @@ export type ExtensionToWebview
| { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string }
| { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[] }
| { type: 'repoLinkBroken', path: string, symbol?: string }
| { type: 'repoLinkOpened', path: string, symbol?: string }
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
| { type: 'repoLinkBroken', path?: string, symbol?: string }
| { type: 'repoLinkOpened', path?: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' }
@@ -454,8 +454,9 @@ export type ExtensionToWebview
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
*/
export type WebviewToExtension
@@ -475,6 +476,6 @@ export type WebviewToExtension
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string }
| { type: 'occurrenceNavigation', available: boolean }
| { type: 'openRepoLink', path: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[] }
| { type: 'openRepoLink', path?: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
@@ -5,7 +5,7 @@ import { onMessage, postMessage } from './vscode'
/** 坏链接悬停上的字,走页面上统一的 data-tooltip,不用原生 title。 */
const MISSING = '找不到'
/** 文件或符号没有的仓库链接:红色波浪线,悬停「找不到」。问到之前先是普通链接。 */
/** 文件或符号没有的仓库链接、找不到的不带路径的引用:红色波浪线,悬停「找不到」。问到之前先是普通链接。 */
function paintLink(anchor: HTMLAnchorElement, broken: boolean): void {
const tip = anchor.getAttribute('data-tooltip')
if (broken) {
@@ -36,16 +36,31 @@ function paint(links: RepoLinks): void {
}
/**
* 注释、说明、问 AI 的回答里的仓库路径链接:点了发给扩展端打开(不走 webview 把 http 链接交给外部打开的那条),
* 画出来之后把路径发去问哪些文件没有。
* 注释、说明、问 AI 的回答里的仓库路径链接,和注释里不带路径的引用:点了发给扩展端打开(不走 webview 把 http 链接交给外部打开的那条),
* 画出来之后把路径、引用发去问哪些文件没有、哪些引用找不到;文档换了版本再问一次(引用跟着源码变)。
*/
export function useRepoLinks(): void {
const [links] = useState(() => new RepoLinks(postMessage))
useEffect(() => {
let timer = 0
const scan = (): void => {
window.clearTimeout(timer)
timer = window.setTimeout(() => {
const anchors = [...document.querySelectorAll<HTMLAnchorElement>('.cr-markdown a')]
for (const anchor of anchors)
paintLink(anchor, links.isBroken(anchor.getAttribute('href') ?? ''))
links.scan(anchors.map(anchor => anchor.getAttribute('href') ?? ''))
}, 50)
}
const off = onMessage((msg) => {
if (msg.type === 'document') {
links.stale()
scan()
return
}
if (msg.type === 'repoLinksChecked') {
if (links.checked(msg.id, msg.missing))
if (links.checked(msg.id, msg.missing, msg.unresolved))
paint(links)
return
}
@@ -67,16 +82,6 @@ export function useRepoLinks(): void {
event.preventDefault()
event.stopPropagation()
}
let timer = 0
const scan = (): void => {
window.clearTimeout(timer)
timer = window.setTimeout(() => {
const anchors = [...document.querySelectorAll<HTMLAnchorElement>('.cr-markdown a')]
for (const anchor of anchors)
paintLink(anchor, links.isBroken(anchor.getAttribute('href') ?? ''))
links.scan(anchors.map(anchor => anchor.getAttribute('href') ?? ''))
}, 50)
}
const observer = new MutationObserver(scan)
observer.observe(document.body, { childList: true, subtree: true })
document.addEventListener('click', onClick, true)