✨ feat(vscode): 代码阅读鼠标停在名字和注释链接上弹出定义所在那块的卡片,别的文件的也行
This commit is contained in:
1 parent
bc7380c716
commit
a48b016d73
20 files changed
+1145
-89
No files matched your search
@@ -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} />)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user