✨ feat(vscode): 代码阅读注释里不带路径的引用 [Name] 能点,本文件定义的优先、再找导入的
This commit is contained in:
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`' }]))
|
||||
|
||||
@@ -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'] },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user