✨ feat(vscode): 注释里的仓库路径链接能点开并跳到符号

This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent 5528a405cc
commit c4113748e1
13 files changed
+662 -18

No files matched your search

+2
View File
@@ -18,6 +18,7 @@ import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks'
import { HighlightContext, useOccurrences } from './useOccurrences'
import { useQa } from './useQa'
import { useRepoLinks } from './useRepoLinks'
import { useReveal } from './useReveal'
import { useRewrite } from './useRewrite'
import { useScrollPosition } from './useScrollPosition'
@@ -82,6 +83,7 @@ export function App(): ReactElement | null {
useScrollPosition(doc)
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
useDefinitionLinks(doc?.version)
useRepoLinks()
const nav = useChangeNav(stops, collapsed, expand)
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
? {
@@ -283,6 +283,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
color: var(--vscode-textLink-activeForeground);
text-decoration: underline;
}
/* 仓库链接指的文件或符号不在:红色波浪线,悬停「找不到」(data-tooltip) */
.cr-markdown a.cr-link-broken,
.cr-markdown a.cr-link-broken:hover {
color: var(--vscode-errorForeground);
text-decoration: underline wavy;
text-underline-offset: 2px;
}
.cr-markdown blockquote {
padding: 0 8px;
border-left: 3px solid var(--vscode-textBlockQuote-border);
@@ -33,13 +33,21 @@ describe('renderMarkdown', () => {
expect(renderMarkdown('改 sample.py 和 README.md')).toBe('<p>改 sample.py 和 README.md</p>\n')
})
it('链接只认 http / https / mailto,别的照原文显示;图片不渲染', () => {
it('危险链接和跑出仓库的路径照原文显示;图片不渲染', () => {
expect(renderMarkdown('[点](javascript:alert(1))')).not.toContain('<a')
expect(renderMarkdown('[命令](command:workbench.action.reloadWindow)')).not.toContain('<a')
expect(renderMarkdown('[相对](./x.md)')).not.toContain('<a')
expect(renderMarkdown('[上级](../secret)')).not.toContain('<a')
expect(renderMarkdown('[行号](file.py#123)')).not.toContain('<a')
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('<img')
})
it('仓库路径链接能点,带类名或类名.成员也一样', () => {
expect(renderMarkdown('[智能体定义](vendor/agents)')).toBe('<p><a href="vendor/agents">智能体定义</a></p>\n')
expect(renderMarkdown('[智能体表](backend/src/luffy_agent/agent/models.py#Agent)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent">智能体表</a></p>\n')
expect(renderMarkdown('[owner 字段](backend/src/luffy_agent/agent/models.py#Agent.owner_id)')).toBe('<p><a href="backend/src/luffy_agent/agent/models.py#Agent.owner_id">owner 字段</a></p>\n')
})
it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => {
expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('<p><a href="https://example.com" data-tooltip="接口说明">文档</a></p>\n')
expect(renderMarkdown('[文档](https://example.com "")')).toBe('<p><a href="https://example.com">文档</a></p>\n')
+5 -3
View File
@@ -1,12 +1,14 @@
import MarkdownIt from 'markdown-it'
import { parseRepoLink } from './repoLink'
/**
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 源码里的 HTML 当文字显示;链接只认 http / https / mailto,别的写法照原文显示——
* webview 会拦下 <a> 的点击交给 VS Code 在外部打开。图片不渲染:webview 的 CSP 不放行外部图片。
* 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名 或 #类名.成员);
* 别的写法照原文显示。webview 会拦下 http 链接的点击交给 VS Code 在外部打开;仓库路径链接由页面另发给扩展端,不走外部打开。
* 图片不渲染:webview 的 CSP 不放行外部图片。
*/
const md = new MarkdownIt({ html: false, linkify: true }).disable('image')
md.validateLink = url => /^(?:https?|mailto):/i.test(url)
md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined
/** 链接的标题(`[文字](网址 "标题")`)写成 data-tooltip,和页面上别的悬停提示走同一个提示框(components/ui/tooltip.tsx),不用原生 title。 */
md.core.ruler.push('link_title_tooltip', (state) => {
@@ -0,0 +1,89 @@
import type { WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest'
import { parseRepoLink, RepoLinks } from './repoLink'
describe('parseRepoLink', () => {
it('合法路径', () => {
expect(parseRepoLink('vendor/agents')).toEqual({ path: 'vendor/agents' })
expect(parseRepoLink('README.md')).toEqual({ path: 'README.md' })
expect(parseRepoLink('vendor/agents/')).toEqual({ path: 'vendor/agents' })
})
it('带类名', () => {
expect(parseRepoLink('backend/src/luffy_agent/agent/models.py#Agent')).toEqual({
path: 'backend/src/luffy_agent/agent/models.py',
symbol: 'Agent',
})
})
it('带类名.成员', () => {
expect(parseRepoLink('backend/src/luffy_agent/agent/models.py#Agent.owner_id')).toEqual({
path: 'backend/src/luffy_agent/agent/models.py',
symbol: 'Agent.owner_id',
})
expect(parseRepoLink('pkg/mod.py#__init__')).toEqual({ path: 'pkg/mod.py', symbol: '__init__' })
})
it('行号不算', () => {
expect(parseRepoLink('file.py#123')).toBeUndefined()
expect(parseRepoLink('file.py#0')).toBeUndefined()
expect(parseRepoLink('file.py#Agent.owner.extra')).toBeUndefined()
})
it('.. 不算', () => {
expect(parseRepoLink('../etc/passwd')).toBeUndefined()
expect(parseRepoLink('foo/../../bar')).toBeUndefined()
expect(parseRepoLink('foo/%2e%2e/bar')).toBeUndefined()
expect(parseRepoLink('./x.md')).toBeUndefined()
expect(parseRepoLink('/etc/passwd')).toBeUndefined()
expect(parseRepoLink('C:/foo')).toBeUndefined()
})
it('http 不算', () => {
expect(parseRepoLink('https://example.com')).toBeUndefined()
expect(parseRepoLink('http://example.com/a')).toBeUndefined()
expect(parseRepoLink('mailto:dev@example.com')).toBeUndefined()
expect(parseRepoLink('javascript:alert(1)')).toBeUndefined()
})
})
describe('repoLinks', () => {
function setup(): { links: RepoLinks, sent: WebviewToExtension[] } {
const sent: WebviewToExtension[] = []
return { links: new RepoLinks(msg => sent.push(msg)), sent }
}
it('点仓库链接发给扩展端,http 不管', () => {
const { links, sent } = setup()
expect(links.click('backend/src/luffy_agent/agent/models.py#Agent.owner_id')).toBe(true)
expect(sent).toEqual([{
type: 'openRepoLink',
path: 'backend/src/luffy_agent/agent/models.py',
symbol: 'Agent.owner_id',
}])
expect(links.click('https://example.com')).toBe(false)
expect(links.click('file.py#123')).toBe(false)
})
it('路径变了才问哪些文件没有,迟到的回答丢掉', () => {
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(links.checked(0, ['vendor/agents'])).toBe(false)
expect(links.isBroken('vendor/agents')).toBe(false)
expect(links.checked(1, ['vendor/agents'])).toBe(true)
expect(links.isBroken('vendor/agents')).toBe(true)
expect(links.isBroken('backend/a.py#Agent')).toBe(false)
})
it('符号没有才标坏;打开成功就不再标', () => {
const { links } = setup()
links.broken('backend/a.py', 'Agent')
expect(links.isBroken('backend/a.py#Agent')).toBe(true)
expect(links.isBroken('backend/a.py')).toBe(false)
links.opened('backend/a.py', 'Agent')
expect(links.isBroken('backend/a.py#Agent')).toBe(false)
})
})
@@ -0,0 +1,134 @@
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))
}
}
+13
View File
@@ -135,6 +135,9 @@ interface BlockViewBase {
attributes: Record<string, string>
/** 这块认出来的概念,从左到右。webview 按图标名画,不自己用种类查 */
concepts: BlockConcept[]
/** 能重写的那段注释的起止行。注释块是整块;函数、类等是它的说明。没有就不显示重写按钮。 */
commentStartLine?: number
commentEndLine?: number
/** 库规则的卡片才有:卡片边框的颜色,筛选栏里这个种类的名字。和类块一样默认收起子块 */
badge?: KindBadge
/** 库规则放进父卡片里的子块才有:画在父卡片属性表第 row 行下面,不排在父卡片的子块里;item 见 BlockSlot */
@@ -395,6 +398,9 @@ export interface DocumentDiff {
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在
*/
export type ExtensionToWebview
= | DocumentMessage
@@ -411,6 +417,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 }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
@@ -429,6 +438,8 @@ export type ExtensionToWebview
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -447,3 +458,5 @@ 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[] }
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import { RepoLinks } from './repoLink'
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) {
anchor.classList.add('cr-link-broken')
if (tip !== MISSING) {
if (tip)
anchor.dataset.linkTooltip = tip
anchor.setAttribute('data-tooltip', MISSING)
}
return
}
if (!anchor.classList.contains('cr-link-broken'))
return
anchor.classList.remove('cr-link-broken')
if (tip === MISSING) {
const prev = anchor.dataset.linkTooltip
if (prev)
anchor.setAttribute('data-tooltip', prev)
else
anchor.removeAttribute('data-tooltip')
}
delete anchor.dataset.linkTooltip
}
function paint(links: RepoLinks): void {
for (const anchor of document.querySelectorAll<HTMLAnchorElement>('.cr-markdown a'))
paintLink(anchor, links.isBroken(anchor.getAttribute('href') ?? ''))
}
/**
* 注释、说明、问 AI 的回答里的仓库路径链接:点了发给扩展端打开(不走 webview 把 http 链接交给外部打开的那条),
* 画出来之后把路径发去问哪些文件没有。
*/
export function useRepoLinks(): void {
const [links] = useState(() => new RepoLinks(postMessage))
useEffect(() => {
const off = onMessage((msg) => {
if (msg.type === 'repoLinksChecked') {
if (links.checked(msg.id, msg.missing))
paint(links)
return
}
if (msg.type === 'repoLinkBroken') {
links.broken(msg.path, msg.symbol)
paint(links)
return
}
if (msg.type === 'repoLinkOpened') {
links.opened(msg.path, msg.symbol)
paint(links)
}
})
// 捕获阶段:赶在 webview 把 <a> 交给外部打开之前拦下仓库链接
const onClick = (event: MouseEvent): void => {
const anchor = event.target instanceof Element ? event.target.closest('a') : null
if (!anchor || !links.click(anchor.getAttribute('href')))
return
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)
document.addEventListener('auxclick', onClick, true)
scan()
return () => {
off()
observer.disconnect()
document.removeEventListener('click', onClick, true)
document.removeEventListener('auxclick', onClick, true)
window.clearTimeout(timer)
}
}, [links])
}