✨ feat(vscode): 代码阅读预览按住 ⌘ / Ctrl 点名字跳到定义

This commit is contained in:
cruldra committed 2026-09-25 12:54:45 +08:00
1 parent e5db1a2e65
commit 4e146eb48f
20 files changed
+870 -60

No files matched your search

+18 -3
View File
@@ -1,11 +1,13 @@
import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree'
import type { DocumentMessage } from './types'
import type { DocumentMessage, Reveal } from './types'
import type { ViewState } from './viewState'
import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { SelectMenu } from '@/components/ui/select-menu'
import { BlockLevel } from './BlockTree'
import { useDefinitionLinks } from './useDefinitionLinks'
import { useReveal } from './useReveal'
import { useScrollPosition } from './useScrollPosition'
import { onMessage, postMessage, viewState } from './vscode'
@@ -24,12 +26,25 @@ export function App(): ReactElement | null {
const [doc, setDoc] = useState<DocumentMessage | null>(null)
const [view, setView] = useState<BlockModes>(savedBlockModes)
const [focusId, setFocusId] = useState<string | null>(null)
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
const [reveal, setReveal] = useState<Reveal | null>(null)
const expand = useCallback((ids: string[]) => setView(prev => ({ ...prev, collapsed: prev.collapsed.filter(id => !ids.includes(id)) })), [])
const revealed = useCallback(() => setReveal(null), [])
useScrollPosition(doc)
useReveal(doc, reveal, view.collapsed, expand, revealed)
useDefinitionLinks(doc?.version)
useEffect(() => {
const off = onMessage((msg) => {
if (msg.type === 'document')
setDoc(msg)
switch (msg.type) {
case 'document':
setDoc(msg)
if (msg.reveal)
setReveal(msg.reveal)
return
case 'reveal':
setReveal({ line: msg.line, definition: msg.definition })
}
})
postMessage({ type: 'ready' })
return off
@@ -83,6 +83,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
<div
className="cr-block flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
data-kind={block.kind}
data-id={block.id}
data-line={block.startLine}
data-editing={editing ? '' : undefined}
>
@@ -1,12 +1,12 @@
import type { ReactElement } from 'react'
import type { Segment } from './types'
/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换。 */
/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,跳到定义用。 */
export function SegmentText({ segments }: { segments: Segment[] }): ReactElement {
return (
<>
{segments.map((segment, index) => (
<span key={index} className={`cr-role-${segment.role}`}>{segment.text}</span>
<span key={index} className={`cr-role-${segment.role}`} data-offset={segment.offset}>{segment.text}</span>
))}
</>
)
@@ -94,11 +94,31 @@ body.vscode-high-contrast-light {
background: var(--kind);
}
/* 跳到定义的目标块:边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */
@keyframes cr-flash {
50% {
background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent);
border-color: var(--vscode-focusBorder);
}
}
@keyframes cr-flash-still {
from, to {
background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent);
border-color: var(--vscode-focusBorder);
}
}
.cr-block[data-flash] {
animation: cr-flash 500ms ease-in-out 3;
}
@media (prefers-reduced-motion: reduce) {
.cr-block,
.cr-guide {
transition: none;
}
.cr-block[data-flash] {
animation: cr-flash-still 1500ms;
}
}
.cr-badge {
@@ -134,6 +154,13 @@ body.vscode-high-contrast-light {
.cr-role-comment { color: var(--cr-comment); }
.cr-role-hint { color: var(--vscode-descriptionForeground); }
/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */
.cr-code [data-link] {
color: var(--vscode-editorLink-activeForeground);
text-decoration: underline;
cursor: pointer;
}
/* 属性表的属性名:界面字体,和值一样的行高,两边逐行对齐 */
.cr-prop-label {
font-size: 12.5px;
@@ -0,0 +1,88 @@
import type { BlockKind, BlockView, PropertyRow, Segment } from './types'
import { describe, expect, it } from 'vitest'
import { revealPlan, scrollTopToShow } from './reveal'
function name(text: string, offset: number): Segment {
return { text, role: 'variable', offset }
}
function word(text: string): Segment {
return { text, role: 'keyword' }
}
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Segment[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, translation }
}
function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, properties }
}
/**
* 0 from luffy_agent.core.db import get_db
* 2 def load(path, user):
* 3 if not user:
* 4 return get_db(path)
* 8 LIMIT = 3
*/
const BLOCKS: BlockView[] = [
factBlock('0', 'import', 0, [
{ label: '来源', value: [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)] },
{ label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 34)]] }] },
]),
factBlock('1', 'function', 2, [
{ label: '名称', value: [{ text: 'load', role: 'function', offset: 46 }] },
{ label: '异步', value: [{ text: '否', role: 'storage' }] },
{ label: '输入参数', value: [{ text: '2 个', role: 'hint' }], items: [{ cells: [[name('path', 51)], []] }, { cells: [[name('user', 57)], []] }] },
], [
textBlock('1.0', 'if', 3, [word('如果'), word('非'), name('user', 78)], [
textBlock('1.0.0', 'return', 4, [word('返回'), name('get_db', 102), name('path', 109)]),
]),
]),
factBlock('2', 'const', 8, [{ label: '名称', value: [name('LIMIT', 130)] }]),
]
describe('跳到定义:滚到哪块、展开哪些', () => {
it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => {
expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
})
it('名字在属性行展开的子行里(参数):连那一行一起展开', () => {
expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] })
expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] })
})
it('名字在属性行的值里:不用展开属性行', () => {
expect(revealPlan(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] })
expect(revealPlan(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] })
})
it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => {
expect(revealPlan(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] })
expect(revealPlan(BLOCKS, 6, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] })
})
it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])]
expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] })
expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] })
expect(revealPlan([], 0, 0)).toBeUndefined()
})
})
describe('把目标块滚进视口', () => {
it('整块已经在视口里:不滚', () => {
expect(scrollTopToShow({ top: 120, bottom: 160 }, 100, 500)).toBeUndefined()
})
it('在视口外:放到视口正中', () => {
expect(scrollTopToShow({ top: 1000, bottom: 1040 }, 0, 500)).toBe(770)
expect(scrollTopToShow({ top: 80, bottom: 120 }, 400, 500)).toBe(0)
})
it('露了一半也算在视口外;比视口还高就顶部对齐', () => {
expect(scrollTopToShow({ top: 580, bottom: 620 }, 100, 500)).toBe(350)
expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 500)).toBe(900)
})
})
@@ -0,0 +1,65 @@
import type { BlockView, Segment } from './types'
import { propertyRowId } from './viewState'
/** 跳到定义时要滚到的块,以及要展开的块和属性行:它们折叠着,目标就不在页面上。 */
export interface RevealPlan {
id: string
expand: string[]
}
interface Placed {
block: BlockView
/** 从顶层到父块的块 id */
ancestors: string[]
}
/** 按页面上的先后(先父块、再子块)列出所有块。 */
function* placed(blocks: BlockView[], ancestors: string[] = []): Generator<Placed> {
for (const block of blocks) {
yield { block, ancestors }
yield* placed(block.children, [...ancestors, block.id])
}
}
function shows(segments: Segment[], offset: number): boolean {
return segments.some(segment => segment.offset === offset)
}
/** 块里哪儿显示着 offset 处的名字:'value' 是一行译文或属性行的值,数字是带子行的属性行序号(名字在它展开的子行里),不在这块里是 undefined。 */
function locate(block: BlockView, offset: number): 'value' | number | undefined {
if ('translation' in block)
return shows(block.translation, offset) ? 'value' : undefined
for (const [index, row] of block.properties.entries()) {
if (shows(row.value, offset))
return 'value'
if (row.items?.some(item => item.cells.some(cell => shows(cell, offset))))
return index
}
return undefined
}
/**
* 跳到定义:目标是显示着定义处那个名字的块,名字在属性行的子行里时连这一行一起展开;
* 页面上找不到这个名字(例如定义是整个模块,落在文件开头)就取包含 line 的块,规则同 blockForLine。
* 目标的祖先块都要展开。
*/
export function revealPlan(blocks: BlockView[], line: number, definition: number): RevealPlan | undefined {
let byLine: Placed | undefined
for (const item of placed(blocks)) {
const where = locate(item.block, definition)
if (where !== undefined)
return { id: item.block.id, expand: typeof where === 'number' ? [...item.ancestors, propertyRowId(item.block.id, where)] : item.ancestors }
if (item.block.startLine <= line && (!byLine || item.block.startLine > byLine.block.startLine))
byLine = item
}
const target = byLine ?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined)
return target && { id: target.block.id, expand: target.ancestors }
}
/** 让一块露出来要滚到的 scrollTop,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。 */
export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number): number | undefined {
if (box.top >= viewTop && box.bottom <= viewTop + viewHeight)
return undefined
const height = box.bottom - box.top
return Math.max(0, height >= viewHeight ? box.top : box.top - (viewHeight - height) / 2)
}
+25 -4
View File
@@ -14,7 +14,8 @@ export type TokenRole
| 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 …
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'
export interface Segment { text: string, role: TokenRole }
/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */
export interface Segment { text: string, role: TokenRole, offset?: number }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值 */
export interface PropertyItem { cells: Segment[][] }
@@ -45,6 +46,15 @@ export type BlockView = BlockViewBase & ({ translation: Segment[] } | { properti
export interface TranslatorOption { id: string, label: string }
/**
* 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
* 要展开把它折叠住的块和属性行,再让那块闪几下
*/
export interface Reveal {
line: number
definition?: number
}
export interface DocumentMessage {
type: 'document'
version: number
@@ -53,18 +63,29 @@ export interface DocumentMessage {
translators: TranslatorOption[]
translatorId: string
error?: string
/** 从文本编辑器切过来时它顶部那一行:只在新预览的第一份 document 里带,滚到包含这行的块 */
revealLine?: number
/** 只在新预览的第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */
reveal?: Reveal
}
export type ExtensionToWebview = DocumentMessage
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:probeDefinition 的回答,found 表示查得到定义
*/
export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', version: number, offset: number, found: boolean }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
* visibleLine:视口顶部那块的起始行,切回文本编辑器时扩展端把这行放到顶部
* probeDefinition:按住 ⌘ / Ctrl 悬停到带下标的名字上,问 offset 处有没有定义;version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
*/
export type WebviewToExtension
= | { type: 'ready' }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', version: number, offset: number }
| { type: 'goToDefinition', version: number, offset: number }
@@ -0,0 +1,124 @@
import type { WebviewToExtension } from './types'
import { useEffect, useState } from 'react'
import { onMessage, postMessage } from './vscode'
/** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.userAgent)
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)
}
/**
* 和 VS Code 编辑器里一样:按住 ⌘ / Ctrl、鼠标停在查得到定义的名字上时,名字显示成链接(data-link),点一下跳到定义。
* 有没有定义问扩展端,回答按文档版本缓存:同一版本里每个名字只问一次,换了版本清空,迟到的旧版本回答丢掉。
*/
class DefinitionLinks {
private version: number | undefined
/** 这个版本里问过的下标:true / false 是回答,undefined 是问了还没回 */
private readonly answers = new Map<number, boolean | undefined>()
private hovered: HTMLElement | null = null
private held = false
private linked: HTMLElement | null = null
constructor(private readonly post: (msg: WebviewToExtension) => void) {}
setVersion(version: number | undefined): void {
if (version === this.version)
return
this.version = version
this.answers.clear()
this.update()
}
point(target: EventTarget | null, held: boolean): void {
this.hovered = nameAt(target)
this.held = held
this.update()
}
hold(held: boolean): void {
this.held = held
this.update()
}
answer(version: number, offset: number, found: boolean): void {
if (version !== this.version)
return
this.answers.set(offset, found)
this.update()
}
/** ⌘ / Ctrl + 点击名字:跳到定义,并拦下这次点击,不让它再触发块上的切换。已知没有定义的名字照常点。 */
click(event: MouseEvent): void {
const offset = modifierHeld(event) ? offsetOf(nameAt(event.target)) : undefined
if (offset === undefined || this.version === undefined || this.answers.get(offset) === false)
return
event.preventDefault()
event.stopPropagation()
this.post({ type: 'goToDefinition', version: this.version, offset })
}
private update(): void {
const offset = this.held ? offsetOf(this.hovered) : undefined
if (offset !== undefined && this.version !== undefined && !this.answers.has(offset)) {
this.answers.set(offset, undefined)
this.post({ type: 'probeDefinition', version: this.version, offset })
}
const link = offset !== undefined && this.answers.get(offset) === true ? this.hovered : null
if (link === this.linked)
return
this.linked?.removeAttribute('data-link')
link?.setAttribute('data-link', '')
this.linked = link
}
}
/** 预览里的名字能 ⌘ / Ctrl + 点击跳到定义;version 是当前显示的文档版本,名字的下标属于它。 */
export function useDefinitionLinks(version: number | undefined): void {
const [links] = useState(() => new DefinitionLinks(postMessage))
useEffect(() => {
links.setVersion(version)
}, [links, version])
useEffect(() => {
const onMove = (event: MouseEvent): void => links.point(event.target, modifierHeld(event))
const onOut = (event: MouseEvent): void => {
if (event.relatedTarget === null)
links.point(null, false)
}
const onKey = (event: KeyboardEvent): void => links.hold(modifierHeld(event))
const onBlur = (): void => links.hold(false)
const onClick = (event: MouseEvent): void => links.click(event)
const off = onMessage((msg) => {
if (msg.type === 'definitionProbed')
links.answer(msg.version, msg.offset, msg.found)
})
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseout', onOut)
// 捕获阶段:先于块里的按钮、折叠箭头拿到点击
document.addEventListener('click', onClick, true)
window.addEventListener('keydown', onKey)
window.addEventListener('keyup', onKey)
window.addEventListener('blur', onBlur)
return () => {
off()
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseout', onOut)
document.removeEventListener('click', onClick, true)
window.removeEventListener('keydown', onKey)
window.removeEventListener('keyup', onKey)
window.removeEventListener('blur', onBlur)
}
}, [links])
}
@@ -0,0 +1,63 @@
import type { DocumentMessage, Reveal } from './types'
import { useLayoutEffect } from 'react'
import { revealPlan, scrollTopToShow } from './reveal'
import { blockForLine } from './scrollPosition'
import { measureBlocks } from './useScrollPosition'
/** 让一块闪几下(codeReader.css 的 [data-flash]);正在闪的同一块再跳一次时从头闪。 */
function flash(el: HTMLElement): void {
const running = el.getAnimations().filter(animation => animation instanceof CSSAnimation)
if (running.length > 0) {
for (const animation of running)
animation.currentTime = 0
return
}
const onEnd = (event: AnimationEvent): void => {
if (event.target !== el)
return
el.removeAttribute('data-flash')
el.removeEventListener('animationend', onEnd)
}
el.addEventListener('animationend', onEnd)
el.setAttribute('data-flash', '')
}
/**
* 滚到 reveal 指的位置,处理完调 done:
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开;
* ② 跳到定义:先展开把目标藏起来的块和属性行(expand 之后重新渲染,再进来一次),再把目标块滚进视口、闪几下。
*/
export function useReveal(
doc: DocumentMessage | null,
reveal: Reveal | null,
collapsed: readonly string[],
expand: (ids: string[]) => void,
done: () => void,
): void {
useLayoutEffect(() => {
if (!doc || !reveal)
return
if (reveal.definition === undefined) {
const top = blockForLine(measureBlocks(), reveal.line)?.top
if (top !== undefined)
window.scrollTo(0, top)
done()
return
}
const plan = revealPlan(doc.blocks, reveal.line, reveal.definition)
const hidden = plan?.expand.filter(id => collapsed.includes(id)) ?? []
if (hidden.length > 0) {
expand(hidden)
return
}
const el = plan && document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`)
if (el) {
const rect = el.getBoundingClientRect()
const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY, window.innerHeight)
if (top !== undefined)
window.scrollTo(0, top)
flash(el)
}
done()
}, [doc, reveal, collapsed, expand, done])
}
@@ -1,14 +1,14 @@
import type { BlockBox } from './scrollPosition'
import type { DocumentMessage } from './types'
import { useEffect, useLayoutEffect, useRef } from 'react'
import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition'
import { anchorAt, scrollTopFor } from './scrollPosition'
import { postMessage, viewState } from './vscode'
/** 滚动时最多这么久记一次位置 */
const RECORD_INTERVAL_MS = 100
/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后。 */
function measureBlocks(): BlockBox[] {
export function measureBlocks(): BlockBox[] {
const y = window.scrollY
return Array.from(document.querySelectorAll<HTMLElement>('.cr-block[data-line]'), (el) => {
const rect = el.getBoundingClientRect()
@@ -31,8 +31,8 @@ function record(reportedLine: { current: number | undefined }): void {
/**
* 记住并恢复预览看到的位置:
* ① 滚动时把视口顶部那块记进 webview 状态,页面因标签页切走被重新加载后照着滚回去;
* ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部;
* ③ 第一份文档带 revealLine(刚从文本编辑器切过来)时优先滚到包含这行的块。
* ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部。
* 第一份文档带 reveal(刚从文本编辑器切过来、跳到定义)时不恢复,交给 useReveal 滚。
*/
export function useScrollPosition(doc: DocumentMessage | null): void {
const restored = useRef(false)
@@ -43,11 +43,8 @@ export function useScrollPosition(doc: DocumentMessage | null): void {
if (!doc || restored.current)
return
restored.current = true
const boxes = measureBlocks()
const saved = viewState.get().scroll
const top = doc.revealLine !== undefined
? blockForLine(boxes, doc.revealLine)?.top
: saved && scrollTopFor(boxes, saved)
const top = doc.reveal === undefined && saved ? scrollTopFor(measureBlocks(), saved) : undefined
if (top !== undefined)
window.scrollTo(0, top)
record(reportedLine)