✨ feat(vscode): 代码阅读鼠标停在名字和注释链接上弹出定义所在那块的卡片,别的文件的也行

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent bc7380c716
commit a48b016d73
20 files changed
+1145 -89

No files matched your search

@@ -0,0 +1,89 @@
import type { Memento } from 'vscode'
import type { BlockView } from '../blockView'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
import { CodeReaderRegistry } from '../registry'
import { runtime, WASM_DIR } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek'
import { documentMessage } from './documentMessage'
const SOURCE = [
'from typing import Annotated',
'',
'class PairedDeviceRepository:',
' """配对设备的仓库"""',
'',
' owner: Annotated[str, Comment("所有者")] = ""',
'',
' def find(self, device_id: str):',
' return device_id',
'',
'',
'repo = PairedDeviceRepository()',
'',
].join('\n')
async function read(): Promise<Awaited<ReturnType<typeof documentMessage>>> {
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
registry.registerLanguage(createPythonAdapter(WASM_DIR))
registry.registerTranslator(pythonZhTranslator)
return documentMessage({ languageId: 'python', version: 1, getText: () => SOURCE }, registry, runtime, () => {})
}
function at(name: string, from = 0): { offset: number, line: number } {
const offset = SOURCE.indexOf(name, from)
return { offset, line: SOURCE.slice(0, offset).split('\n').length - 1 }
}
describe('定义卡片取哪块', () => {
it('定义是块自己的名字:取那块,连同子块', async () => {
const { message } = await read()
const { offset, line } = at('PairedDeviceRepository')
const block = peekBlock(message.blocks, offset, line)
expect(block?.kind).toBe('class')
expect(block?.children.map(child => child.kind)).toContain('function')
})
it('定义是参数:取参数所在的函数那块', async () => {
const { message } = await read()
const { offset, line } = at('device_id')
const block = peekBlock(message.blocks, offset, line)
expect(block?.kind).toBe('function')
expect(block?.defines?.startIndex).toBe(at('find').offset)
})
it('定义是变量:取变量那块;定义在块外面:没有', async () => {
const { message } = await read()
const repo = at('repo =')
expect(peekBlock(message.blocks, repo.offset, repo.line)?.kind).toBe('variable')
expect(peekBlock(message.blocks, SOURCE.length, 99)).toBeUndefined()
})
it('挂在父卡片子行上的子块:浮窗里是最外层的卡片,不带 slot', () => {
const child = { id: '0.0', startLine: 2, endLine: 3, defines: { startIndex: 30, endIndex: 34 }, slot: { row: 1, item: 0 }, children: [] } as unknown as BlockView
const parent = { id: '0', startLine: 0, endLine: 5, children: [child] } as unknown as BlockView
const block = peekBlock([parent], 31, 2)
expect(block?.id).toBe('0.0')
expect(block && 'slot' in block).toBe(false)
})
})
describe('卡片上的中文名', () => {
it('别的文件:起了中文名的定义本身,标成定义处;只带卡片上画出来的', async () => {
const { message, displayNames } = await read()
const names = definitionSiteNames(displayNames)
const { offset, line } = at('PairedDeviceRepository')
const owner = { offset: at('owner').offset, length: 'owner'.length, name: '所有者', definition: true }
expect(namesOnCard(peekBlock(message.blocks, offset, line)!, names)).toEqual([owner])
const repo = at('repo =')
expect(namesOnCard(peekBlock(message.blocks, repo.offset, repo.line)!, names)).toEqual([])
})
it('下标对上、长度对不上的不带', async () => {
const { message } = await read()
const { offset, line } = at('PairedDeviceRepository')
const owner = at('owner').offset
expect(namesOnCard(peekBlock(message.blocks, offset, line)!, [{ offset: owner, length: 3, name: '所有者' }])).toEqual([])
})
})
@@ -0,0 +1,50 @@
import type { BlockView } from '../blockView'
import type { DisplayName } from '../contract'
import type { ShownName } from '../nameResolution'
import { renderedNames } from '../nameResolution'
function covers(block: BlockView, offset: number): boolean {
return block.defines !== undefined && block.defines.startIndex <= offset && offset < block.defines.endIndex
}
/** 自己的名字(defines)盖住 offset 的最里层那块。 */
function namedAt(blocks: readonly BlockView[], offset: number): BlockView | undefined {
for (const block of blocks) {
const inner = namedAt(block.children, offset)
if (inner)
return inner
if (covers(block, offset))
return block
}
return undefined
}
/** 编辑范围盖住 line 的最里层那块;同一行上并排的兄弟块取第一块。 */
function innermostAt(blocks: readonly BlockView[], line: number): BlockView | undefined {
const outer = blocks.find(block => block.startLine <= line && line <= block.endLine)
return outer && (innermostAt(outer.children, line) ?? outer)
}
/**
* 定义卡片取哪块:定义处(下标 offset,在第 line 行)是哪块自己的名字(函数名、类名、单个变量名)就取那块,
* 否则取编辑范围盖住定义那一行的最里层那块(参数、导入项、字段落在它们所在的块里)。都没有(定义在块外面)就没有。
* 挂在父卡片子行上的子块(slot)在浮窗里是最外层那张卡片,去掉 slot 照常画。
*/
export function peekBlock(blocks: readonly BlockView[], offset: number, line: number): BlockView | undefined {
const found = namedAt(blocks, offset) ?? innermostAt(blocks, line)
if (!found?.slot)
return found
const { slot: _slot, ...card } = found
return card
}
/** 卡片上画出来的名字(照抄自源码,下标和长度都对得上)显示成什么中文名;别的不带过去。 */
export function namesOnCard(block: BlockView, names: readonly ShownName[]): ShownName[] {
const rendered = renderedNames([block])
return names.filter(name => rendered.get(name.offset)?.length === name.length)
}
/** 别的文件里起了中文名的定义本身显示成中文名(定义处标 definition,同预览);用到它们的各处要问语言扩展,浮窗不等。 */
export function definitionSiteNames(names: readonly DisplayName[]): ShownName[] {
return names.map(({ name, text }) => ({ offset: name.startIndex, length: name.endIndex - name.startIndex, name: text, definition: true as const }))
}
+20
View File
@@ -34,6 +34,17 @@ export interface Occurrence {
kind: 'text' | 'read' | 'write'
}
/**
* 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径,同挑选框)、定义处那一行(0 起),
* 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的)。
*/
export interface DefinitionPeek {
path: string
line: number
block: BlockView
names: ShownName[]
}
export interface DocumentMessage {
type: 'document'
/** TextDocument.version:webview 靠它判断是不是新一份文档 */
@@ -89,6 +100,9 @@ export interface QaMessage {
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义
* definitionPeeked:id 那次 peekDefinition / peekLink 的回答。查不到定义、定义所在的语言没有代码阅读、没有块盖住定义处、
* 指着的名字就是定义本身时没有 peek,页面不弹浮窗
* hoverDelay:VS Code 的 editor.hover.delay / editor.hover.hidingDelay(毫秒),鼠标停多久弹定义卡片、移开后多久收;ready 后发一次,设置改了再发
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
@@ -110,6 +124,8 @@ export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'definitionPeeked', id: number, peek?: DefinitionPeek }
| { type: 'hoverDelay', delay: number, hidingDelay: number }
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'moveOccurrence', step: 1 | -1 }
| { type: 'displayNames', version: number, names: ShownName[] }
@@ -133,6 +149,8 @@ export type ExtensionToWebview
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去;
* version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* peekDefinition:鼠标停在带下标的名字上,要 offset 处名字的定义卡片(几处定义取第一处);id、version 同 probeDefinition
* peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol 同 openRepoLink
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
@@ -161,6 +179,8 @@ export type WebviewToExtension
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', id: number, version: number, offset: number }
| { type: 'goToDefinition', version: number, offset: number }
| { type: 'peekDefinition', id: number, version: number, offset: number }
| { type: 'peekLink', id: number, path?: string, symbol: string }
| { type: 'findOccurrences', id: number, version: number, offset: number }
| { type: 'ask', askId: string, anchor: string, question: string, images: ClaudeImage[] }
| { type: 'stopAsk', askId: string }
+101 -22
View File
@@ -13,7 +13,7 @@ import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks'
import type { IndexedEntry } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter'
import type { FoundLocation } from './definitions'
import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import { realpath } from 'node:fs/promises'
import { dirname } from 'node:path'
@@ -33,6 +33,7 @@ import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
import { resolveReference } from '../symbolLinks'
import { definitionAction } from './definitionAction'
import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek'
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage'
@@ -259,6 +260,10 @@ class Preview {
this.names.edited(document.version, e.contentChanges.map(change => ({ offset: change.rangeOffset, length: change.rangeLength, text: change.text })))
this.schedulePush()
}),
workspace.onDidChangeConfiguration((e) => {
if (this.ready && e.affectsConfiguration('editor.hover', document))
this.postHoverDelay()
}),
// 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发
filter.subscribe((state) => {
if (this.ready)
@@ -533,6 +538,7 @@ class Preview {
this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', ...this.filter.current })
this.postHoverDelay()
this.postQa()
void this.postProfiles()
await this.push()
@@ -579,6 +585,12 @@ class Preview {
case 'goToDefinition':
await this.goToDefinition(msg.version, msg.offset)
return
case 'peekDefinition':
await this.peekDefinition(msg.id, msg.version, msg.offset)
return
case 'peekLink':
await this.peekLink(msg.id, msg.path, msg.symbol)
return
case 'findOccurrences':
await this.findOccurrences(msg.id, msg.version, msg.offset)
return
@@ -644,6 +656,73 @@ class Preview {
}
}
/** VS Code 的悬停延迟(按这份文件取,语言自己的设置优先):页面按它弹、收定义卡片。 */
private postHoverDelay(): void {
const hover = workspace.getConfiguration('editor.hover', this.document)
this.post({ type: 'hoverDelay', delay: hover.get<number>('delay', 300), hidingDelay: hover.get<number>('hidingDelay', 300) })
}
/**
* 回答 webview 第 id 次要定义卡片:offset 处名字的第一处定义所在那块(见 peekAt);指着的名字就是定义本身时没有。
* 下标属于别的文档版本时不答,webview 换了文档版本会收起浮窗
*/
private async peekDefinition(id: number, version: number, offset: number): Promise<void> {
const { document } = this
if (version !== document.version)
return
const [definition] = await findDefinitions(await this.definitions.locate(version, offset))
const own = definition?.uri === document.uri.toString() && definition.offset === offset
const peek = definition && !own ? await this.peekAt(definition.location.uri, definition.offset) : undefined
if (version === document.version && this.ready)
this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) })
}
/** 回答 webview 第 id 次要注释里的链接指的符号的卡片:符号同点这个链接时找的那个(symbolTarget),找不到没有。 */
private async peekLink(id: number, rel: string | undefined, symbol: string): Promise<void> {
let peek: DefinitionPeek | undefined
try {
const target = await this.symbolTarget(rel, symbol)
peek = target && await this.peekAt(Uri.parse(target.uri), target.selection.startIndex)
}
catch (err) {
const link = rel === undefined ? `[${symbol}]` : `${rel}#${symbol}`
logger.add({ level: 'warn', source: 'codeReader', message: `没能找 ${link} 指的符号:${err instanceof Error ? err.message : String(err)}` })
}
if (this.ready)
this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) })
}
/**
* uri 里 offset 处的定义的卡片:盖住它的那块(peekBlock)。同一份文件用最近发出去的那份文档的块和中文名;
* 别的文件打开它、按它的语言排视图数据(同它自己的预览:翻译插件、库规则、项目入口;规则查名字的定义、调用的种类不等),
* 中文名只认起了中文名的定义本身。语言没有代码阅读、解析失败、没有块盖住定义处时没有。
*/
private async peekAt(uri: Uri, offset: number): Promise<DefinitionPeek | undefined> {
try {
const target = await workspace.openTextDocument(uri)
const line = target.positionAt(offset).line
const path = workspace.asRelativePath(uri)
if (uri.toString() === this.document.uri.toString()) {
const { sent } = this
const block = sent?.version === target.version ? peekBlock(sent.blocks, offset, line) : undefined
return block && { path, line, block, names: namesOnCard(block, this.names.known(target.version)) }
}
const { message, displayNames } = await documentMessage(
{ languageId: target.languageId, version: target.version, getText: () => target.getText(), fileName: target.fileName, entryNames: this.owner.entries.namesOf(uri) },
this.registry,
this.runtime,
// 那个文件里规则出的错由它自己的预览记
() => {},
)
const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined
return block && { path, line, block, names: namesOnCard(block, definitionSiteNames(displayNames)) }
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` })
return undefined
}
}
/**
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开,给了 spot 就滚过去、闪这处;
* 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置,
@@ -732,45 +811,33 @@ class Preview {
private async openRepoLink(rel: string | undefined, symbol?: string): Promise<void> {
const link = rel ?? `[${symbol}]`
try {
if (rel === undefined) {
const target = symbol === undefined ? undefined : await this.resolveSymbolLink(symbol)
if (symbol !== undefined) {
const target = await this.symbolTarget(rel, symbol)
if (!target) {
this.markRepoLinkBroken(undefined, symbol)
this.markRepoLinkBroken(rel, symbol)
return
}
await this.showLinkTarget(target)
this.markRepoLinkOpened(undefined, symbol)
this.markRepoLinkOpened(rel, symbol)
return
}
if (rel === undefined)
return
const root = await repoRootOf(this.document.uri.fsPath)
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
if (!entry) {
this.markRepoLinkBroken(rel, symbol)
this.markRepoLinkBroken(rel)
return
}
const uri = Uri.file(entry.fsPath)
if (entry.isDirectory) {
if (symbol !== undefined) {
this.markRepoLinkBroken(rel, symbol)
return
}
if (!await this.owner.revealInTree(uri))
void window.showInformationMessage(`代码阅读:侧栏的树上没有 ${rel}`)
this.markRepoLinkOpened(rel)
return
}
if (symbol === undefined) {
await this.openElsewhere(uri)
this.markRepoLinkOpened(rel)
return
}
const found = await this.locateRepoSymbol(uri, symbol)
if (!found) {
this.markRepoLinkBroken(rel, symbol)
return
}
await this.showLinkTarget(found)
this.markRepoLinkOpened(rel, symbol)
await this.openElsewhere(uri)
this.markRepoLinkOpened(rel)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -779,6 +846,18 @@ class Preview {
}
}
/**
* 链接指的符号在哪:没有 path 的是不带路径的引用,按这份文件找(resolveSymbolLink);有 path 的在那个文件的文档符号里找(locateRepoSymbol)。
* 文件没有、是目录、符号没有时没有。点链接、悬停链接看定义卡片都按它找。
*/
private async symbolTarget(rel: string | undefined, symbol: string): Promise<LinkTarget | undefined> {
if (rel === undefined)
return this.resolveSymbolLink(symbol)
const root = await repoRootOf(this.document.uri.fsPath)
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
return entry && !entry.isDirectory ? this.locateRepoSymbol(Uri.file(entry.fsPath), symbol) : undefined
}
/** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<LinkTarget | undefined> {
const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol)
+20 -14
View File
@@ -8,6 +8,7 @@ import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AskThread } from './AskThread'
import { BlockLevel } from './BlockTree'
import { DefinitionPeekPopup } from './DefinitionPeekPopup'
import { DiffBar, NewFileBanner } from './DiffBar'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
@@ -18,6 +19,7 @@ import { OverviewRuler } from './OverviewRuler'
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks'
import { useDefinitionPeek } from './useDefinitionPeek'
import { HighlightContext, useOccurrences } from './useOccurrences'
import { useQa } from './useQa'
import { useRepoImages } from './useRepoImages'
@@ -88,6 +90,7 @@ export function App(): ReactElement | null {
useScrollPosition(doc)
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
useDefinitionLinks(doc?.version)
const peek = useDefinitionPeek(doc?.version)
useRepoLinks()
useRepoImages()
const nav = useChangeNav(stops, collapsed, expand)
@@ -143,27 +146,29 @@ export function App(): ReactElement | null {
const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc])
const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames])
const tree = useMemo((): BlockTreeState | null => doc && {
version: doc.version,
editing: new Set(view.editing),
edit: {
version: doc.version,
editing: new Set(view.editing),
focusId,
onFocused,
onToggleEdit: (id) => {
setFocusId(view.editing.includes(id) ? null : id)
setView(prev => ({
...prev,
editing: prev.editing.includes(id) ? prev.editing.filter(other => other !== id) : startEditing(doc.blocks, prev.editing, id),
}))
},
rewrites: rewrites.runs,
onRewriteComment: rewrites.onRewrite,
onStopRewrite: rewrites.onStop,
},
collapsed,
hidden: filter.hidden,
temporary: filter.temporary,
covered,
kindName,
focusId,
onFocused,
onToggleEdit: (id) => {
setFocusId(view.editing.includes(id) ? null : id)
setView(prev => ({
...prev,
editing: prev.editing.includes(id) ? prev.editing.filter(other => other !== id) : startEditing(doc.blocks, prev.editing, id),
}))
},
onToggleFold: id => setFolded([id], !collapsed.has(id)),
onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })),
rewrites: rewrites.runs,
onRewriteComment: rewrites.onRewrite,
onStopRewrite: rewrites.onStop,
...(qa.state.available && { qa }),
...(changes && { diff: changes }),
...(folded && { folded }),
@@ -236,6 +241,7 @@ export function App(): ReactElement | null {
? <BlockLevel blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
<DefinitionPeekPopup shown={peek.shown} onJump={peek.jump} />
</DisplayNameContext>
</HighlightContext>
)
@@ -53,21 +53,23 @@ const CARD: BlockView = {
function render(folds: Record<string, boolean>): string {
const collapsed = collapsedIds([CARD], folds)
const tree: BlockTreeState = {
version: 1,
editing: new Set(),
edit: {
version: 1,
editing: new Set(),
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
},
collapsed,
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
kindName: kind => kind,
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
onToggleFold: () => {},
onSetFolds: () => {},
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
onOpenRun: () => {},
}
return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={tree} />)
+41 -29
View File
@@ -18,9 +18,27 @@ import { asksOf, historyOf } from './qa'
import { TextPreview } from './TextPreview'
import { tableId } from './viewState'
export interface BlockTreeState {
/**
* 块树里改代码的那部分:哪些块在编辑、点「编辑」、按注释规范重写。页面上的块树才有;定义卡片浮窗里的块树没有,
* 卡片只读,不画右上角的按钮
*/
export interface EditTree {
version: number
editing: ReadonlySet<string>
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
onFocused: () => void
onToggleEdit: (id: string) => void
/** 按注释规范重写的过程,按块的记号。只在这一页上 */
rewrites: ReadonlyMap<string, RewriteRun>
onRewriteComment: (anchor: string) => void
/** 叫停这块正在进行的重写,不写文件 */
onStopRewrite: (anchor: string) => void
}
export interface BlockTreeState {
/** 能编辑(页面上的块树);定义卡片浮窗里没有 */
edit?: EditTree
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
collapsed: ReadonlySet<string>
/** 按筛选不画的块(连同子孙),见 applyFilter */
@@ -31,18 +49,9 @@ export interface BlockTreeState {
covered: ReadonlyMap<string, string>
/** 种类叫什么(种类图标的悬停提示和 aria-label):翻译插件给种类起的名字(库规则的种类是规则给的 badge.label),没给就是 id */
kindName: (kind: string) => string
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
onFocused: () => void
onToggleEdit: (id: string) => void
onToggleFold: (id: string) => void
/** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */
onSetFolds: (folds: Record<string, boolean>) => void
/** 按注释规范重写的过程,按块的记号。只在这一页上 */
rewrites: ReadonlyMap<string, RewriteRun>
onRewriteComment: (anchor: string) => void
/** 叫停这块正在进行的重写,不写文件 */
onStopRewrite: (anchor: string) => void
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
qa?: QaTree
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
@@ -152,7 +161,7 @@ export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: B
<BlockCard block={block} tree={tree} />
</ChangeRow>
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
<BlockRewrite anchor={block.anchor} tree={tree} />
{tree.edit && <BlockRewrite anchor={block.anchor} edit={tree.edit} />}
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
@@ -184,11 +193,11 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
)
}
function BlockRewrite({ anchor, tree }: { anchor: string, tree: BlockTreeState }): ReactElement | null {
const run = tree.rewrites.get(anchor)
function BlockRewrite({ anchor, edit }: { anchor: string, edit: EditTree }): ReactElement | null {
const run = edit.rewrites.get(anchor)
if (!run)
return null
return <RewriteThread run={run} onStop={() => tree.onStopRewrite(anchor)} />
return <RewriteThread run={run} onStop={() => edit.onStopRewrite(anchor)} />
}
/**
@@ -233,9 +242,10 @@ function cardSlots(block: BlockView, tree: BlockTreeState): GridSlots | undefine
}
}
/** 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */
/** 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。 */
function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
const editing = tree.editing.has(block.id)
const { edit } = tree
const editing = edit?.editing.has(block.id) ?? false
const collapsed = tree.collapsed.has(block.id)
// 点父卡片子行才展开的子块:画出来就是展开着的,箭头收起整张
const toggled = block.slot !== undefined
@@ -267,14 +277,14 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
: <span className="w-3.5 shrink-0" />}
<KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1">
{editing
{edit && editing
? (
<CodeEditor
block={block}
version={tree.version}
autoFocus={tree.focusId === block.id}
onAutoFocused={tree.onFocused}
onExit={() => tree.onToggleEdit(block.id)}
version={edit.version}
autoFocus={edit.focusId === block.id}
onAutoFocused={edit.onFocused}
onExit={() => edit.onToggleEdit(block.id)}
/>
)
: <BlockPreview block={block} tree={tree} marks={tree.diff?.current ? change?.marks : undefined} />}
@@ -285,14 +295,16 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
已被筛选隐藏,临时显示
</span>
)}
<BlockTools
editing={editing}
onToggleEdit={() => tree.onToggleEdit(block.id)}
asking={tree.qa?.state.open.includes(block.anchor)}
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
rewriting={tree.rewrites.get(block.anchor)?.status === 'running'}
onRewrite={block.commentStartLine !== undefined ? () => tree.onRewriteComment(block.anchor) : undefined}
/>
{edit && (
<BlockTools
editing={editing}
onToggleEdit={() => edit.onToggleEdit(block.id)}
asking={tree.qa?.state.open.includes(block.anchor)}
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
rewriting={edit.rewrites.get(block.anchor)?.status === 'running'}
onRewrite={block.commentStartLine !== undefined ? () => edit.onRewriteComment(block.anchor) : undefined}
/>
)}
</div>
)
}
@@ -0,0 +1,66 @@
import type { BlockView, DefinitionPeek } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { PeekTree } from './DefinitionPeekPopup'
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
function block(id: string, kind: string, extra: Partial<BlockView>): BlockView {
return {
id,
anchor: id,
kind,
startLine: 0,
endLine: 0,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [{ id: kind, label: kind, icon: 'box' }],
children: [],
translation: [],
...extra,
} as BlockView
}
const NAME = 'PairedDeviceRepository'
const PEEK: DefinitionPeek = {
path: 'app/devices.py',
line: 41,
block: block('3', 'class', {
commentStartLine: 42,
commentEndLine: 42,
properties: [
{ label: '名称', value: [{ text: NAME, role: 'type', offset: 1200 }] },
{ label: '说明', value: { markdown: '只在桌面端用,见 [DesktopOnly]' } },
],
children: [block('3.0', 'function', { translation: [{ text: '查找设备', role: 'plain' }] })],
} as Partial<BlockView>),
names: [{ offset: 1200, length: NAME.length, name: '配对设备仓库', definition: true }],
}
describe('定义卡片浮窗里的卡片', () => {
const html = renderToStaticMarkup(<PeekTree peek={PEEK} />)
it('同页面上那张卡片:类默认收起子块', () => {
expect(html).toContain('cr-block')
expect(html).toContain('aria-label="展开子块"')
expect(html).not.toContain('查找设备')
})
it('只读:没有编辑、问 AI、重写注释的按钮', () => {
expect(html).not.toContain('aria-label="编辑"')
expect(html).not.toContain('aria-label="问 AI"')
expect(html).not.toContain('aria-label="按注释规范重写"')
})
it('中文名用卡片带来的(下标是那个文件的)', () => {
expect(html).toContain('data-name="配对设备仓库"')
expect(html).toContain(`<span class="cr-original">${NAME}</span>`)
})
it('注释里不带路径的引用不成链接:它按注释所在的文件找,页面只替自己这份文件问', () => {
expect(html).not.toContain('symbol:DesktopOnly')
expect(html).toContain('[DesktopOnly]')
})
})
@@ -0,0 +1,90 @@
import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree'
import type { ShownPeek } from './definitionPeek'
import type { DefinitionPeek } from './types'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { BlockLevel } from './BlockTree'
import { PEEK_ATTRIBUTE, placePeek } from './definitionPeek'
import { DisplayNameContext, nameMap } from './displayNames'
import { MarkdownKindContext } from './TextPreview'
import { HighlightContext } from './useOccurrences'
import { collapsedIds } from './viewState'
const NONE: ReadonlySet<string> = new Set()
const NOT_COVERED: ReadonlyMap<string, string> = new Map()
/**
* 浮窗里的那张卡片:只读的块树(不编辑、不问 AI、不重写注释、不标差异、不筛选),折叠展开只记在浮窗里,默认同页面(类、函数收起子块)。
* 名字的下标是那个文件的:中文名换成卡片带来的,不画页面上的高亮;Markdown 不认不带路径的引用。
*/
export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement {
const [folds, setFolds] = useState<Record<string, boolean>>({})
const collapsed = useMemo(() => collapsedIds([peek.block], folds), [peek, folds])
const names = useMemo(() => nameMap(peek.names), [peek])
const tree: BlockTreeState = {
collapsed,
hidden: NONE,
temporary: NONE,
covered: NOT_COVERED,
kindName: kind => kind,
onToggleFold: id => setFolds(prev => ({ ...prev, [id]: !collapsed.has(id) })),
onSetFolds: next => setFolds(prev => ({ ...prev, ...next })),
// 不标差异就没有「只看改动」折起来的块
onOpenRun: () => {},
}
return (
<HighlightContext value={undefined}>
<DisplayNameContext value={names}>
<MarkdownKindContext value="peek">
<BlockLevel blocks={[peek.block]} tree={tree} />
</MarkdownKindContext>
</DisplayNameContext>
</HighlightContext>
)
}
/**
* 定义卡片浮窗:标题一行「相对路径:行号」,点了跳到定义(同 ⌘ / Ctrl + 点击);下面是卡片,太大时在浮窗里滚。
* 外框用 VS Code 悬停框的颜色;portal 到 body,先在左上角量出大小,再落到名字下方或上方(placePeek)。
*/
export function DefinitionPeekPopup({ shown, onJump }: { shown: ShownPeek<HTMLElement> | undefined, onJump: () => void }): ReactElement | null {
const popupRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
const popup = popupRef.current
if (!shown || !popup)
return
Object.assign(popup.style, { top: '0px', left: '0px', bottom: '', maxWidth: '', maxHeight: '', visibility: 'hidden' })
const place = placePeek(shown.anchor.getBoundingClientRect(), popup.getBoundingClientRect(), { width: window.innerWidth, height: window.innerHeight })
Object.assign(popup.style, {
top: 'top' in place ? `${place.top}px` : '',
bottom: 'bottom' in place ? `${place.bottom}px` : '',
left: `${place.left}px`,
maxWidth: `${place.maxWidth}px`,
maxHeight: `${place.maxHeight}px`,
visibility: 'visible',
})
}, [shown])
if (!shown)
return null
const { peek } = shown
const where = `${peek.path}:${peek.line + 1}`
return createPortal(
<div
ref={popupRef}
{...{ [PEEK_ATTRIBUTE]: '' }}
role="dialog"
aria-label={`定义 ${where}`}
className="cr-peek"
style={{ top: 0, left: 0, visibility: 'hidden' }}
>
<button type="button" className="cr-peek-head" data-tooltip="跳到定义" onClick={onJump}>{where}</button>
<div className="cr-peek-body">
<PeekTree key={`${where}#${peek.block.id}`} peek={peek} />
</div>
</div>,
document.body,
)
}
@@ -1,13 +1,19 @@
import type { ReactElement } from 'react'
import type { MarkdownKind } from './markdown'
import type { PreviewText } from './types'
import { createContext, useContext } from 'react'
import { cn } from '@/lib/utils'
import { renderMarkdown } from './markdown'
import { SegmentText } from './SegmentText'
/** 卡片里的 Markdown 说明按哪种渲染:页面上是注释;定义卡片浮窗里是 peek(别的文件的注释,不认不带路径的引用)。 */
export const MarkdownKindContext = createContext<Exclude<MarkdownKind, 'answer'>>('comment')
/** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */
export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement {
const kind = useContext(MarkdownKindContext)
if ('markdown' in text)
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown, 'comment') }} />
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown, kind) }} />
return (
<p className={cn('cr-code', className)}>
<SegmentText segments={text} />
@@ -330,6 +330,54 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
color: var(--vscode-descriptionForeground);
}
/*
* 悬停名字、链接时的定义卡片浮窗:外框同 VS Code 编辑器的悬停框(边框、底色、阴影),标题一行垫悬停框状态栏的底色。
* 落位时按剩下的空间限宽高(行内样式),卡片再大就在下面那块里滚;那块自己也有个上限,大文件的类不至于占满半屏。
* 层级低于悬停提示(z-50),浮窗里名字的提示照样盖在它上面
*/
.cr-peek {
position: fixed;
z-index: 40;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border));
border-radius: 6px;
background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background));
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%));
}
.cr-peek-head {
flex-shrink: 0;
padding: 2px 8px;
border-bottom: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border));
background: var(--vscode-editorHoverWidget-statusBarBackground, transparent);
color: var(--vscode-textLink-foreground);
font-family: var(--vscode-editor-font-family, monospace);
font-size: 12px;
line-height: 18px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.cr-peek-head:hover {
color: var(--vscode-textLink-activeForeground);
text-decoration: underline;
}
.cr-peek-head:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.cr-peek-body {
min-height: 0;
max-width: 640px;
max-height: 420px;
overflow: auto;
padding: 6px 8px 6px 0;
}
/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */
.cr-code [data-link] {
color: var(--vscode-editorLink-activeForeground);
@@ -0,0 +1,240 @@
import type { PeekTarget, ShownPeek } from './definitionPeek'
import type { DefinitionPeek, WebviewToExtension } from './types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DefinitionPeeks, linkPeekSource, placePeek } from './definitionPeek'
const DELAY = 300
const HIDING = 500
const PEEK: DefinitionPeek = {
path: 'app/devices.py',
line: 41,
block: { id: '3', anchor: '3', kind: 'class', startLine: 41, endLine: 60, code: '', codeTokens: [], attributes: { kind: 'class' }, concepts: [], children: [], translation: [] },
names: [],
}
interface Anchor { name: string }
function setup(version: number | undefined = 7): {
peeks: DefinitionPeeks<Anchor>
sent: WebviewToExtension[]
shown: () => ShownPeek<Anchor> | undefined
asks: () => { id: number }[]
} {
const sent: WebviewToExtension[] = []
let current: ShownPeek<Anchor> | undefined
const peeks = new DefinitionPeeks<Anchor>(msg => sent.push(msg), (next) => {
current = next
})
peeks.setDelays(DELAY, HIDING)
peeks.setVersion(version)
const asks = (): { id: number }[] => sent.flatMap(msg => msg.type === 'peekDefinition' || msg.type === 'peekLink' ? [{ id: msg.id }] : [])
return { peeks, sent, shown: () => current, asks }
}
function name(offset: number): PeekTarget<Anchor> {
return { anchor: { name: `n${offset}` }, source: { offset } }
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('定义卡片什么时候弹', () => {
it('停够 editor.hover.delay 才问,问的是这份文档版本里这个下标;回答带卡片才弹', () => {
const { peeks, sent, shown } = setup()
const target = name(120)
peeks.point(target)
vi.advanceTimersByTime(DELAY - 1)
expect(sent).toEqual([])
vi.advanceTimersByTime(1)
expect(sent).toEqual([{ type: 'peekDefinition', id: 1, version: 7, offset: 120 }])
peeks.answer(1, PEEK)
expect(shown()).toEqual({ anchor: target.anchor, peek: PEEK, jump: { type: 'goToDefinition', version: 7, offset: 120 } })
})
it('没到时间就移开:不问', () => {
const { peeks, sent } = setup()
peeks.point(name(120))
vi.advanceTimersByTime(DELAY / 2)
peeks.point(null)
vi.advanceTimersByTime(DELAY * 2)
expect(sent).toEqual([])
})
it('回答没有卡片:不弹', () => {
const { peeks, shown } = setup()
peeks.point(name(120))
vi.advanceTimersByTime(DELAY)
peeks.answer(1, undefined)
expect(shown()).toBeUndefined()
})
it('问了之后移开、或换了名字:迟到的回答丢掉', () => {
const { peeks, shown, asks } = setup()
peeks.point(name(120))
vi.advanceTimersByTime(DELAY)
peeks.point(name(200))
vi.advanceTimersByTime(DELAY)
const [first, second] = asks()
peeks.answer(first.id, PEEK)
expect(shown()).toBeUndefined()
peeks.answer(second.id, PEEK)
expect(shown()?.anchor).toEqual({ name: 'n200' })
})
it('注释里指到符号的链接:问 peekLink,点标题发的是点这个链接时发的 openRepoLink', () => {
const { peeks, sent, shown } = setup()
const anchor = { name: 'link' }
peeks.point({ anchor, source: { path: 'app/devices.py', symbol: 'Repo.find' } })
vi.advanceTimersByTime(DELAY)
expect(sent).toEqual([{ type: 'peekLink', id: 1, path: 'app/devices.py', symbol: 'Repo.find' }])
peeks.answer(1, PEEK)
peeks.jump()
expect(sent.at(-1)).toEqual({ type: 'openRepoLink', path: 'app/devices.py', symbol: 'Repo.find' })
expect(shown()).toBeUndefined()
})
it('还没收到 VS Code 的悬停延迟:不弹', () => {
const sent: WebviewToExtension[] = []
const peeks = new DefinitionPeeks<Anchor>(msg => sent.push(msg), () => {})
peeks.setVersion(1)
peeks.point(name(5))
vi.advanceTimersByTime(10_000)
expect(sent).toEqual([])
})
})
describe('定义卡片什么时候收', () => {
function showing(): ReturnType<typeof setup> & { target: PeekTarget<Anchor> } {
const context = setup()
const target = name(120)
context.peeks.point(target)
vi.advanceTimersByTime(DELAY)
context.peeks.answer(1, PEEK)
return { ...context, target }
}
it('移开名字:过 editor.hover.hidingDelay 才收,期间移进浮窗就不收', () => {
const { peeks, shown } = showing()
peeks.point(null)
vi.advanceTimersByTime(HIDING - 1)
expect(shown()).toBeDefined()
peeks.enterPopup()
vi.advanceTimersByTime(HIDING * 3)
expect(shown()).toBeDefined()
})
it('移出浮窗到空白处:过一会儿收;移回名字就不收', () => {
const { peeks, shown, target } = showing()
peeks.enterPopup()
peeks.point(null)
vi.advanceTimersByTime(HIDING / 2)
peeks.point(target)
vi.advanceTimersByTime(HIDING * 3)
expect(shown()).toBeDefined()
peeks.point(null)
vi.advanceTimersByTime(HIDING)
expect(shown()).toBeUndefined()
})
it('从名字移向浮窗时扫过别的名字:不把收的时间往后推', () => {
const { peeks, shown } = showing()
peeks.point(name(300))
vi.advanceTimersByTime(HIDING / 2)
peeks.point(name(400))
vi.advanceTimersByTime(HIDING / 2)
expect(shown()).toBeUndefined()
})
it('扫过别的名字后进了浮窗:不收,也不弹那个名字的卡片', () => {
const { peeks, shown, asks } = showing()
peeks.point(name(300))
vi.advanceTimersByTime(DELAY / 2)
peeks.enterPopup()
vi.advanceTimersByTime(HIDING * 3)
expect(shown()?.anchor).toEqual({ name: 'n120' })
expect(asks()).toHaveLength(1)
})
it('指着浮窗里的东西不弹别的卡片', () => {
const { peeks, asks } = showing()
peeks.enterPopup()
vi.advanceTimersByTime(DELAY * 3)
expect(asks()).toHaveLength(1)
})
it('移到另一个名字、它有卡片:换成它的', () => {
const { peeks, shown, asks } = showing()
peeks.point(name(300))
vi.advanceTimersByTime(DELAY)
peeks.answer(asks()[1].id, PEEK)
expect(shown()?.anchor).toEqual({ name: 'n300' })
vi.advanceTimersByTime(HIDING * 3)
expect(shown()?.anchor).toEqual({ name: 'n300' })
})
it('移到另一个名字、它没有卡片:原来的照常过一会儿收', () => {
const { peeks, shown, asks } = showing()
peeks.point(name(300))
vi.advanceTimersByTime(DELAY)
peeks.answer(asks()[1].id, undefined)
expect(shown()?.anchor).toEqual({ name: 'n120' })
vi.advanceTimersByTime(HIDING - DELAY)
expect(shown()).toBeUndefined()
})
it('按 Esc、滚动、按下鼠标(dismiss)立刻收;指针不离开这个名字不再弹', () => {
const { peeks, shown, asks, target } = showing()
peeks.dismiss()
expect(shown()).toBeUndefined()
peeks.point(target)
vi.advanceTimersByTime(DELAY * 3)
expect(asks()).toHaveLength(1)
})
it('换了文档版本:收起,作罢没问完的', () => {
const { peeks, shown, asks } = showing()
peeks.point(name(300))
peeks.setVersion(8)
expect(shown()).toBeUndefined()
vi.advanceTimersByTime(DELAY * 3)
expect(asks()).toHaveLength(1)
})
})
describe('链接能不能弹定义卡片', () => {
it('指到符号的才能:带 #符号 的仓库链接、不带路径的引用', () => {
expect(linkPeekSource('app/devices.py#Repo.find')).toEqual({ path: 'app/devices.py', symbol: 'Repo.find' })
expect(linkPeekSource('symbol:DesktopOnly')).toEqual({ symbol: 'DesktopOnly' })
})
it('只指到文件、目录的,网址:不能', () => {
expect(linkPeekSource('app/devices.py')).toBeUndefined()
expect(linkPeekSource('app/')).toBeUndefined()
expect(linkPeekSource('https://example.com#x')).toBeUndefined()
})
})
describe('浮窗放在哪', () => {
const viewport = { width: 1000, height: 800 }
it('下方放得下:贴着名字下边往下长,左边对齐名字', () => {
expect(placePeek({ top: 100, bottom: 120, left: 300 }, { width: 400, height: 300 }, viewport))
.toEqual({ top: 124, left: 300, maxWidth: 692, maxHeight: 668 })
})
it('下方放不下、上方空间更大:底边贴着名字上边往上长', () => {
expect(placePeek({ top: 600, bottom: 620, left: 300 }, { width: 400, height: 300 }, viewport))
.toEqual({ bottom: 204, left: 300, maxWidth: 692, maxHeight: 588 })
})
it('两边都放不下:放空间大的那边;右边放不下往左挪,钳在视口内', () => {
expect(placePeek({ top: 300, bottom: 320, left: 900 }, { width: 400, height: 900 }, viewport))
.toEqual({ top: 324, left: 592, maxWidth: 400, maxHeight: 468 })
})
})
@@ -0,0 +1,221 @@
import type { DefinitionPeek, WebviewToExtension } from './types'
import { parseCommentLink } from './repoLink'
/** 定义卡片浮窗的根上的属性:浮窗里的名字、链接是别的文件的,页面上管名字的那几处(跳到定义、高亮各处出现、再弹卡片)都不认。 */
export const PEEK_ATTRIBUTE = 'data-peek'
/** 能弹定义卡片的东西:带源码下标的名字,或注释里指到符号的链接(带 #符号 的仓库链接、不带路径的引用)。 */
export type PeekSource = { offset: number } | { path?: string, symbol: string }
/** 指针下能弹定义卡片的一处:页面上的哪个元素(浮窗挂在它旁边),它是什么。 */
export interface PeekTarget<A> {
anchor: A
source: PeekSource
}
/** 弹着的定义卡片:挂在哪个元素旁边、卡片,点标题时发什么(同 ⌘ / Ctrl + 点击这个名字、点这个链接)。 */
export interface ShownPeek<A> {
anchor: A
peek: DefinitionPeek
jump: WebviewToExtension
}
/** 元素在不在定义卡片浮窗里。 */
export function inPeek(target: Element): boolean {
return target.closest(`[${PEEK_ATTRIBUTE}]`) !== null
}
/** 事件落在的那个页面上这份文档的名字(SegmentText 渲染的 data-offset);浮窗里的名字下标是别的文件的,不算。 */
export function documentNameAt(target: EventTarget | null): HTMLElement | null {
const name = target instanceof Element ? target.closest<HTMLElement>('[data-offset]') : null
return name && !inPeek(name) ? name : null
}
/** 注释里一个链接地址能不能弹定义卡片:指到符号的才能(带 #符号 的仓库链接、不带路径的引用),只指到文件、目录的不能。 */
export function linkPeekSource(href: string): PeekSource | undefined {
const link = parseCommentLink(href)
if (link?.symbol === undefined)
return undefined
return link.path === undefined ? { symbol: link.symbol } : { path: link.path, symbol: link.symbol }
}
/** 浮窗与名字之间的间距。 */
const GAP_PX = 4
/** 贴边留白,浮窗不顶到 webview 边缘。 */
const GUTTER_PX = 8
/**
* 浮窗落在哪:top 是在名字下方、从这儿往下长,bottom 是在名字上方、底边离视口底这么远、往上长(卡片展开时不盖住名字);
* maxWidth、maxHeight 是那边还剩的空间,卡片再大就在浮窗里滚。
*/
export type PeekPlacement = { left: number, maxWidth: number, maxHeight: number } & ({ top: number } | { bottom: number })
/**
* 浮窗放在哪(同 VS Code 编辑器里的悬停):优先在名字下方,下方放不下、上方空间更大就放上方。
* 左边对齐名字,钳在视口内。size 是浮窗不受限时的大小。
*/
export function placePeek(
anchor: Pick<DOMRect, 'top' | 'bottom' | 'left'>,
size: Pick<DOMRect, 'width' | 'height'>,
viewport: { width: number, height: number },
): PeekPlacement {
const below = viewport.height - GUTTER_PX - (anchor.bottom + GAP_PX)
const above = anchor.top - GAP_PX - GUTTER_PX
const left = Math.max(GUTTER_PX, Math.min(anchor.left, viewport.width - GUTTER_PX - size.width))
const maxWidth = viewport.width - GUTTER_PX - left
if (size.height <= below || below >= above)
return { top: anchor.bottom + GAP_PX, left, maxWidth, maxHeight: below }
return { bottom: viewport.height - (anchor.top - GAP_PX), left, maxWidth, maxHeight: above }
}
/**
* 定义卡片的浮窗什么时候弹、换成谁、什么时候收(同 VS Code 编辑器里的悬停;不碰 DOM,页面把指针、按键、滚动喂进来,anchor 是页面上的元素):
* 鼠标停在能弹的名字、链接上 delay 毫秒后才问扩展端要卡片,没到时间就移开的作罢;回答带卡片才弹,同一时刻只有一个浮窗。
* 移开名字、浮窗两者 hidingDelay 毫秒后收,期间移进浮窗或移回名字就不收;指针在浮窗里时不弹别的。
* Esc、滚动、按下鼠标、换了文档版本立刻收。只认最近一次询问的回答,迟到的丢掉。两个延迟都取 VS Code 的设置(hoverDelay),没收到之前不弹。
*/
export class DefinitionPeeks<A> {
private version: number | undefined
private delays: { delay: number, hidingDelay: number } | undefined
/** 指针下能弹卡片的那一处(浮窗外);没指着的、指针在浮窗里时是 null */
private hovered: A | null = null
private inPopup = false
private askTimer: ReturnType<typeof setTimeout> | undefined
private hideTimer: ReturnType<typeof setTimeout> | undefined
private request = 0
/** 最近一次发出去、还没答的询问 */
private asked: { id: number, anchor: A, jump: WebviewToExtension } | undefined
private shown: ShownPeek<A> | undefined
constructor(
private readonly post: (msg: WebviewToExtension) => void,
private readonly onChange: (shown: ShownPeek<A> | undefined) => void,
) {}
/** VS Code 的 editor.hover.delay / editor.hover.hidingDelay */
setDelays(delay: number, hidingDelay: number): void {
this.delays = { delay, hidingDelay }
}
/** 换了一份文档:名字的下标换了意思,收起浮窗、作罢没问完的;指针下的名字等它再动时重新认 */
setVersion(version: number | undefined): void {
if (version === this.version)
return
this.version = version
this.hovered = null
this.dismiss()
}
/** 指针在浮窗外:指着能弹卡片的一处,或没指着(null) */
point(target: PeekTarget<A> | null): void {
const anchor = target?.anchor ?? null
if (!this.inPopup && anchor === this.hovered)
return
this.inPopup = false
this.hovered = anchor
this.cancelAsk()
if (this.shown) {
if (anchor === this.shown.anchor)
this.cancelHide()
else
this.scheduleHide()
}
if (target && anchor !== this.shown?.anchor)
this.scheduleAsk(target)
}
/** 指针移进浮窗:不收,也不弹别的 */
enterPopup(): void {
if (this.inPopup)
return
this.inPopup = true
this.hovered = null
this.cancelAsk()
this.cancelHide()
}
/** id 那次询问的回答;不是最近一次的、指针已经移开的丢掉 */
answer(id: number, peek: DefinitionPeek | undefined): void {
if (this.asked?.id !== id)
return
const { anchor, jump } = this.asked
this.asked = undefined
if (!peek)
return
this.cancelHide()
this.setShown({ anchor, peek, jump })
}
/** 点了浮窗的标题:跳到定义(同 ⌘ / Ctrl + 点击名字、点链接),收起浮窗 */
jump(): void {
const jump = this.shown?.jump
this.dismiss()
if (jump)
this.post(jump)
}
/** 立刻收,作罢没问完的;指针不离开眼下这一处就不再弹 */
dismiss(): void {
this.inPopup = false
this.cancelAsk()
this.cancelHide()
this.setShown(undefined)
}
dispose(): void {
clearTimeout(this.askTimer)
clearTimeout(this.hideTimer)
}
private scheduleAsk(target: PeekTarget<A>): void {
if (!this.delays)
return
this.askTimer = setTimeout(() => {
this.askTimer = undefined
this.ask(target)
}, this.delays.delay)
}
private ask({ anchor, source }: PeekTarget<A>): void {
const id = ++this.request
if ('offset' in source) {
if (this.version === undefined)
return
const { version } = this
this.asked = { id, anchor, jump: { type: 'goToDefinition', version, offset: source.offset } }
this.post({ type: 'peekDefinition', id, version, offset: source.offset })
return
}
const link = source.path === undefined ? { symbol: source.symbol } : { path: source.path, symbol: source.symbol }
this.asked = { id, anchor, jump: { type: 'openRepoLink', ...link } }
this.post({ type: 'peekLink', id, ...link })
}
private cancelAsk(): void {
clearTimeout(this.askTimer)
this.askTimer = undefined
this.asked = undefined
}
/** 已经在等着收的不重新计时:从名字移向浮窗时扫过别的名字,不把收的时间往后推 */
private scheduleHide(): void {
if (this.hideTimer !== undefined || !this.delays)
return
this.hideTimer = setTimeout(() => {
this.hideTimer = undefined
this.setShown(undefined)
}, this.delays.hidingDelay)
}
private cancelHide(): void {
clearTimeout(this.hideTimer)
this.hideTimer = undefined
}
private setShown(shown: ShownPeek<A> | undefined): void {
if (shown === this.shown)
return
this.shown = shown
this.onChange(shown)
}
}
@@ -102,6 +102,11 @@ describe('不带路径的引用', () => {
expect(renderMarkdown('见 [Agent]', 'answer')).toBe('<p>见 [Agent]</p>\n')
expect(renderMarkdownDiff([{ text: '见 ' }, { text: '[Agent]', change: 'add' }])).toContain('href="symbol:Agent"')
})
it('定义卡片浮窗里别的文件的注释不认(引用按注释所在的文件找);仓库路径链接照样是链接', () => {
expect(renderMarkdown('见 [Agent]', 'peek')).toBe('<p>见 [Agent]</p>\n')
expect(renderMarkdown('见 [Agent](app/models.py#Agent)', 'peek')).toContain('<a href="app/models.py#Agent">')
})
})
describe('renderMarkdownDiff', () => {
+5 -2
View File
@@ -82,8 +82,11 @@ md.core.ruler.push('link_title_tooltip', (state) => {
const CACHE_SIZE = 500
const cache = new Map<string, string>()
/** 渲染的是什么:注释(说明、注释块,认不带路径的引用),还是问 AI 的回答。 */
export type MarkdownKind = 'comment' | 'answer'
/**
* 渲染的是什么:注释(说明、注释块,认不带路径的引用),定义卡片浮窗里的注释(不认不带路径的引用:引用按注释所在的文件找,
* 页面只替自己这份文件问),还是问 AI 的回答。
*/
export type MarkdownKind = 'comment' | 'peek' | 'answer'
function envOf(kind: MarkdownKind): RenderEnv {
return { symbolLinks: kind === 'comment' }
+19
View File
@@ -206,6 +206,17 @@ export interface Occurrence {
kind: 'text' | 'read' | 'write'
}
/**
* 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径)、定义处那一行(0 起),
* 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的,不是页面上这份文档的)
*/
export interface DefinitionPeek {
path: string
line: number
block: BlockView
names: ShownName[]
}
export interface DocumentMessage {
type: 'document'
version: number
@@ -400,6 +411,8 @@ export interface DocumentDiff {
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
* definitionPeeked:id 那次 peekDefinition / peekLink 的回答。没有 peek(查不到定义、语言没有代码阅读、没有块盖住定义处、指着的就是定义本身)就不弹浮窗
* hoverDelay:VS Code 的 editor.hover.delay / editor.hover.hidingDelay(毫秒),鼠标停多久弹定义卡片、移开后多久收;ready 后来一次,设置改了再来
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
@@ -420,6 +433,8 @@ export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'definitionPeeked', id: number, peek?: DefinitionPeek }
| { type: 'hoverDelay', delay: number, hidingDelay: number }
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'moveOccurrence', step: 1 | -1 }
| { type: 'displayNames', version: number, names: ShownName[] }
@@ -443,6 +458,8 @@ export type ExtensionToWebview
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来;
* version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* peekDefinition:鼠标停在带下标的名字上,要 offset 处名字的定义卡片(几处定义取第一处);id、version 同 probeDefinition
* peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol 同 openRepoLink
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
* setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示
@@ -470,6 +487,8 @@ export type WebviewToExtension
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', id: number, version: number, offset: number }
| { type: 'goToDefinition', version: number, offset: number }
| { type: 'peekDefinition', id: number, version: number, offset: number }
| { type: 'peekLink', id: number, path?: string, symbol: string }
| { type: 'findOccurrences', id: number, version: number, offset: number }
| { type: 'ask', askId: string, anchor: string, question: string, images: AskImage[] }
| { type: 'stopAsk', askId: string }
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { DefinitionLinks } from './definitionLinks'
import { documentNameAt } from './definitionPeek'
import { onMessage, postMessage } from './vscode'
/** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */
@@ -9,11 +10,6 @@ function modifierHeld(event: MouseEvent | KeyboardEvent): boolean {
return IS_MAC ? event.metaKey : event.ctrlKey
}
/** 事件落在的那个带源码下标的名字(SegmentText 渲染的 data-offset)。 */
function nameAt(target: EventTarget | null): HTMLElement | null {
return target instanceof Element ? target.closest<HTMLElement>('[data-offset]') : null
}
function offsetOf(el: HTMLElement | null): number | undefined {
return el?.dataset.offset === undefined ? undefined : Number(el.dataset.offset)
}
@@ -27,7 +23,7 @@ class Pointer {
constructor(private readonly links: DefinitionLinks) {}
move(target: EventTarget | null, held: boolean): void {
this.hovered = nameAt(target)
this.hovered = documentNameAt(target)
this.held = held
this.sync()
}
@@ -73,7 +69,7 @@ export function useDefinitionLinks(version: number | undefined): void {
const onBlur = (): void => pointer.hold(false)
// ⌘ / Ctrl + 点击名字归跳转:拦下这次点击,不让它再触发块上的编辑切换、折叠
const onClick = (event: MouseEvent): void => {
if (!links.click(modifierHeld(event) ? offsetOf(nameAt(event.target)) : undefined))
if (!links.click(modifierHeld(event) ? offsetOf(documentNameAt(event.target)) : undefined))
return
event.preventDefault()
event.stopPropagation()
@@ -0,0 +1,86 @@
import type { PeekTarget, ShownPeek } from './definitionPeek'
import { useEffect, useState } from 'react'
import { DefinitionPeeks, documentNameAt, inPeek, linkPeekSource } from './definitionPeek'
import { onMessage, postMessage } from './vscode'
/** 指针下能弹定义卡片的一处:注释里指到符号的链接,或页面上这份文档带下标的名字;浮窗里的不算。 */
function peekTargetAt(target: EventTarget | null): PeekTarget<HTMLElement> | null {
if (!(target instanceof Element) || inPeek(target))
return null
const link = target.closest<HTMLAnchorElement>('.cr-markdown a')
if (link) {
const source = linkPeekSource(link.getAttribute('href') ?? '')
return source ? { anchor: link, source } : null
}
const name = documentNameAt(target)
return name?.dataset.offset === undefined ? null : { anchor: name, source: { offset: Number(name.dataset.offset) } }
}
function isInPeek(target: EventTarget | null): boolean {
return target instanceof Element && inPeek(target)
}
/**
* 和 VS Code 编辑器里的悬停一样:鼠标停在带下标的名字、注释里指到符号的链接上,过一会儿(editor.hover.delay)弹出定义所在那块的卡片,
* 移开名字和浮窗过一会儿(editor.hover.hidingDelay)收起,Esc、滚动、按下鼠标立刻收(见 DefinitionPeeks)。
* version 是当前显示的文档版本,名字的下标属于它。返回弹着的卡片和点标题时的跳转。
*/
export function useDefinitionPeek(version: number | undefined): { shown: ShownPeek<HTMLElement> | undefined, jump: () => void } {
const [shown, setShown] = useState<ShownPeek<HTMLElement> | undefined>()
const [peeks] = useState(() => new DefinitionPeeks<HTMLElement>(postMessage, setShown))
useEffect(() => {
peeks.setVersion(version)
}, [peeks, version])
useEffect(() => {
const onOver = (event: PointerEvent): void => {
if (isInPeek(event.target))
peeks.enterPopup()
else
peeks.point(peekTargetAt(event.target))
}
const onOut = (event: PointerEvent): void => {
if (event.relatedTarget === null)
peeks.point(null)
}
const onDown = (event: PointerEvent): void => {
if (!isInPeek(event.target))
peeks.dismiss()
}
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape')
peeks.dismiss()
}
// 捕获阶段:页面和页面里能滚的框滚动都收;浮窗自己里面滚不收
const onScroll = (event: Event): void => {
if (!isInPeek(event.target))
peeks.dismiss()
}
// hoverDelay 在页面 ready 后、第一份文档之前就来,挂在页面根上的 App 一开始就收
const off = onMessage((msg) => {
if (msg.type === 'hoverDelay')
peeks.setDelays(msg.delay, msg.hidingDelay)
else if (msg.type === 'definitionPeeked')
peeks.answer(msg.id, msg.peek)
else if (msg.type === 'reveal')
peeks.dismiss()
})
document.addEventListener('pointerover', onOver)
document.addEventListener('pointerout', onOut)
document.addEventListener('pointerdown', onDown, true)
window.addEventListener('keydown', onKey)
window.addEventListener('scroll', onScroll, true)
return () => {
off()
document.removeEventListener('pointerover', onOver)
document.removeEventListener('pointerout', onOut)
document.removeEventListener('pointerdown', onDown, true)
window.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll, true)
peeks.dispose()
}
}, [peeks])
return { shown, jump: () => peeks.jump() }
}
@@ -1,6 +1,7 @@
import type { Click, Highlight, ScrollPosition } from './occurrences'
import type { BlockView, Occurrence } from './types'
import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek'
import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences'
import { occurrencePlan } from './reveal'
import { showInViewport } from './useReveal'
@@ -9,8 +10,8 @@ import { onMessage, postMessage } from './vscode'
/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色、给当前那一处描边。 */
export const HighlightContext = createContext<Highlight | undefined>(undefined)
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label'
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动。定义卡片浮窗里的名字是别的文件的,点了也不动 */
const CONTROLS = `a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label, [${PEEK_ATTRIBUTE}]`
/** 不收文字的输入框:焦点在它们上面时方向键不归它们移光标 */
const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset'])
@@ -28,7 +29,7 @@ export interface OccurrencePage {
function clickOf(event: MouseEvent): Click {
const target = event.target instanceof Element ? event.target : null
const name = target?.closest<HTMLElement>('[data-offset]')
const name = documentNameAt(target)
const selection = window.getSelection()
return {
modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey,