✨ feat(vscode): 代码阅读的函数和类可以看调用树

This commit is contained in:
cruldra committed 2026-10-11 00:37:24 -04:00
1 parent 8dc572bf94
commit 645542eb5c
17 files changed
+1679 -5

No files matched your search

+64 -2
View File
@@ -1,13 +1,14 @@
import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree'
import type { NameMap, VersionNames } from './displayNames'
import type { DocumentDiff, DocumentMessage, Excluded, Reveal } from './types'
import type { BlockView, CallTree, DocumentDiff, DocumentMessage, Excluded, Reveal } from './types'
import type { OccurrencePage } from './useOccurrences'
import type { ViewState } from './viewState'
import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AskThread } from './AskThread'
import { BlockLevel } from './BlockTree'
import { CallTreeDialog } from './CallTreeDialog'
import { DefinitionPeekPopup } from './DefinitionPeekPopup'
import { DiffBar, NewFileBanner } from './DiffBar'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
@@ -42,6 +43,17 @@ function savedBlockModes(): BlockModes {
return { editing, folds }
}
function blockKind(blocks: readonly BlockView[], anchor: string): string {
for (const block of blocks) {
if (block.anchor === anchor)
return block.kind
const inner = blockKind(block.children, anchor)
if (inner)
return inner
}
return ''
}
export function App(): ReactElement | null {
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
const qa = useQa()
@@ -64,6 +76,18 @@ export function App(): ReactElement | null {
const [diff, setDiff] = useState<DocumentDiff | null>(null)
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
const editing = useRef(view.editing)
/** 页面上这份文档的版本:调用树的回答不是这一版就丢掉 */
const docVersion = useRef<number | undefined>(undefined)
const [callCounts, setCallCounts] = useState<{ version: number, counts: Record<string, number> }>()
const [openCall, setOpenCall] = useState<{
anchor: string
version: number
status: 'loading' | 'ready'
name: string
where: string
kind: string
trees: CallTree[]
} | null>(null)
/** 块树所在的页面内容:钉住卡片时在这里面量 */
const content = useRef<HTMLDivElement>(null)
// 解析失败的文档没有块,不标差异;删掉的块按块筛选同样藏
@@ -113,12 +137,29 @@ export function App(): ReactElement | null {
const off = onMessage((msg) => {
switch (msg.type) {
case 'document':
docVersion.current = msg.version
setDoc(msg)
setNames(documentNames(msg.version, msg.names))
setKeep(prev => keptWhileEditing(prev, editing.current))
setCallCounts(prev => prev?.version === msg.version ? prev : undefined)
setOpenCall(prev => prev?.version === msg.version ? prev : null)
if (msg.reveal)
setReveal(msg.reveal)
return
case 'callTreeCounts':
if (msg.version !== docVersion.current)
return
setCallCounts({ version: msg.version, counts: msg.counts })
return
case 'callTree':
if (msg.version !== docVersion.current)
return
setOpenCall((prev) => {
if (!prev || prev.anchor !== msg.anchor || prev.version !== msg.version)
return prev
return { ...prev, status: 'ready', name: msg.name, where: msg.where, trees: msg.trees }
})
return
case 'reveal':
setReveal({ line: msg.line, definition: msg.definition })
return
@@ -148,6 +189,12 @@ export function App(): ReactElement | null {
}, [view])
const onFocused = useCallback(() => setFocusId(null), [])
const onOpenCall = useCallback((anchor: string) => {
if (!doc)
return
setOpenCall({ anchor, version: doc.version, status: 'loading', name: '', where: '', kind: blockKind(doc.blocks, anchor) || 'function', trees: [] })
postMessage({ type: 'openCallTree', version: doc.version, anchor })
}, [doc])
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 && {
@@ -178,7 +225,12 @@ export function App(): ReactElement | null {
...(changes && { diff: changes }),
...(folded && { folded }),
onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])),
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded])
callTree: {
counts: callCounts?.version === doc.version ? new Map(Object.entries(callCounts.counts)) : new Map(),
...(openCall?.version === doc.version && { openAnchor: openCall.anchor }),
onOpen: onOpenCall,
},
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded, callCounts, openCall, onOpenCall])
if (!doc || !tree)
return null
@@ -248,6 +300,16 @@ export function App(): ReactElement | null {
</div>
{sticky?.enabled && <StickyLines content={content} blocks={doc.blocks} settings={sticky} />}
<DefinitionPeekPopup shown={peek.shown} onJump={peek.jump} />
{openCall && (
<CallTreeDialog
kind={openCall.kind}
name={openCall.name}
where={openCall.where}
status={openCall.status === 'loading' ? 'loading' : openCall.trees.length === 0 ? 'empty' : 'ready'}
trees={openCall.trees}
onClose={() => setOpenCall(null)}
/>
)}
</DisplayNameContext>
</HighlightContext>
)
@@ -2,13 +2,14 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { BlockTools } from './BlockTools'
function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean }): string {
function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean }, callTree?: { count: number }): string {
return renderToStaticMarkup(
<BlockTools
editing={editing}
onToggleEdit={() => {}}
{...(ask && { asking: ask.asking, onToggleAsk: () => {} })}
{...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, onRewrite: () => {} } })}
{...(callTree && { callTree: { anchor: 'fn', count: callTree.count, open: false, onOpen: () => {} } })}
/>,
)
}
@@ -64,4 +65,17 @@ describe('块右上角的图标按钮', () => {
expect(buttons[1]).toContain('data-tooltip="问 AI"')
expect(buttons[2]).toContain('data-tooltip="编辑"')
})
it('调用树棵数大于 0 时排在编辑后面,按钮上是这个数字;棵数是 0 不出现', () => {
const html = render(false, { asking: false }, { rewriting: false }, { count: 3 })
const buttons = [...html.matchAll(/<button[^>]*>/g)].map(match => match[0])
expect(buttons).toHaveLength(4)
expect(buttons[2]).toContain('data-tooltip="编辑"')
expect(buttons[3]).toContain('data-tooltip="调用树" aria-label="调用树"')
expect(buttons[3]).toContain('data-call-tree="fn"')
expect(visibleText(html)).toBe('3')
const hidden = render(false, { asking: false }, { rewriting: false }, { count: 0 })
expect(hidden).not.toContain('调用树')
expect(hidden.match(/<button/g)).toHaveLength(3)
})
})
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
import { Eye, MessageSquareText, Pencil, Sparkles } from 'lucide-react'
import { Eye, GitBranch, MessageSquareText, Pencil, Sparkles } from 'lucide-react'
/** 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点。 */
export interface RewriteTool {
@@ -14,7 +14,7 @@ export interface RewriteTool {
* 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。
* 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
*/
export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite }: {
export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite, callTree }: {
editing: boolean
onToggleEdit: () => void
/** 块下面的问答区展开着 */
@@ -23,6 +23,8 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
onToggleAsk?: () => void
/** 没给就不显示「按注释规范重写」(不能写注释的块) */
rewrite?: RewriteTool
/** 没给,或棵数是 0,就不显示调用树(还没查到、没有调用者、语言不提供) */
callTree?: { anchor: string, count: number, open: boolean, onOpen: () => void }
}): ReactElement {
const label = editing ? '预览' : '编辑'
const Icon = editing ? Eye : Pencil
@@ -65,6 +67,21 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
>
<Icon size={14} strokeWidth={1.8} aria-hidden />
</button>
{callTree && callTree.count > 0 && (
<button
type="button"
className="cr-tool flex h-5 items-center justify-center gap-px rounded px-1"
data-call-tree={callTree.anchor}
data-active={callTree.open ? '' : undefined}
data-tooltip="调用树"
aria-label="调用树"
aria-expanded={callTree.open}
onClick={callTree.onOpen}
>
<GitBranch size={14} strokeWidth={1.8} aria-hidden />
<span className="cr-call-count">{callTree.count}</span>
</button>
)}
</div>
)
}
@@ -37,9 +37,18 @@ export interface EditTree {
onStopRewrite: (anchor: string) => void
}
/** 函数、类落在几棵调用树里。定义浮窗不传,跟问 AI 一样不画这排按钮。 */
export interface CallTreeTools {
counts: ReadonlyMap<string, number>
openAnchor?: string
onOpen: (anchor: string) => void
}
export interface BlockTreeState {
/** 能编辑(页面上的块树);定义卡片浮窗里没有 */
edit?: EditTree
/** 调用树。只读的树不传 */
callTree?: CallTreeTools
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
collapsed: ReadonlySet<string>
/** 按筛选不画的块(连同子孙),见 applyFilter */
@@ -227,6 +236,18 @@ function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTre
}
/** 这块的「重写注释」按钮,排在右上角那排按钮里:能写注释(commentTarget)、页面上能改代码时才有。 */
function callTreeTool(block: BlockView, tree: BlockTreeState) {
const callTree = tree.callTree
if (!callTree || (block.kind !== 'function' && block.kind !== 'class'))
return undefined
return {
anchor: block.anchor,
count: callTree.counts.get(block.anchor) ?? 0,
open: callTree.openAnchor === block.anchor,
onOpen: () => callTree.onOpen(block.anchor),
}
}
function rewriteTool(block: BlockView, edit: EditTree | undefined): RewriteTool | undefined {
if (!edit || !block.commentTarget)
return undefined
@@ -319,6 +340,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
asking={tree.qa?.state.open.includes(block.anchor)}
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
rewrite={rewriteTool(block, edit)}
callTree={callTreeTool(block, tree)}
/>
)}
</div>
@@ -0,0 +1,225 @@
// @vitest-environment happy-dom
import type { ReactElement } from 'react'
import type { Root } from 'react-dom/client'
import type { BlockTreeState } from './BlockTree'
import type { BlockView, CallTree } from './types'
import { act, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockLevel } from './BlockTree'
import { CallTreeDialog } from './CallTreeDialog'
vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react')
return { HighlightContext: createContext(undefined) }
})
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
let container: HTMLDivElement
let root: Root
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.unstubAllGlobals()
})
async function render(element: ReactElement): Promise<void> {
await act(async () => root.render(element))
}
function block(kind: string, id = kind): BlockView {
return {
id,
anchor: id,
kind,
startLine: 0,
endLine: 1,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }],
children: [],
translation: [{ text: id, role: 'plain' }],
}
}
function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeState {
return {
...(edit && {
edit: {
version: 1,
editing: new Set<string>(),
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
},
}),
collapsed: new Set(),
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
kindName: kind => kind,
onToggleFold: () => {},
onSetFolds: () => {},
onOpenRun: () => {},
...(callTree && { callTree }),
}
}
function node(name: string, extra: Partial<CallTree['root']> = {}): CallTree['root'] {
return { name, kind: 'function', where: `${name}.py:1`, current: false, children: [], ...extra }
}
const ONE: CallTree = {
id: 'one',
name: '夜间对账',
hits: 1,
root: node('夜间对账', { detail: '入口', children: [node('mask_phone', { current: true, where: 'utils/phone.py:28' })] }),
}
const MANY: CallTree[] = [
{
id: 'read',
name: '读取用户资料',
hits: 2,
root: node('读取用户资料', {
detail: '入口',
otherCalls: 2,
children: [node('mask_phone', { current: true }), node('mask_phone', { current: true })],
}),
},
ONE,
]
describe('调用树弹框', () => {
it('棵数为 0 的函数没有按钮,类有,语句没有;只读的树一排按钮都没有', async () => {
const counts = new Map([['function', 0], ['class', 2], ['statement', 4]])
await render(<BlockLevel blocks={[block('function'), block('class'), block('statement')]} tree={tree({ counts, onOpen: () => {} })} />)
expect(container.querySelector('[data-call-tree="function"]')).toBeNull()
expect(container.querySelector('[data-call-tree="statement"]')).toBeNull()
expect(container.querySelector('[data-call-tree="class"]')).not.toBeNull()
expect(container.querySelector('[data-call-tree="class"]')!.textContent).toBe('2')
await render(<BlockLevel blocks={[block('function')]} tree={tree({ counts: new Map([['function', 3]]), onOpen: () => {} }, false)} />)
expect(container.querySelector('[aria-label="块操作"]')).toBeNull()
expect(container.querySelector('[data-call-tree="function"]')).toBeNull()
})
it('弹框打开时原来的卡片还在,关掉之后弹框没了,焦点回到那个按钮', async () => {
const card = block('function', 'mask')
function Page(): ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<BlockLevel
blocks={[card]}
tree={tree({ counts: new Map([['mask', 3]]), openAnchor: open ? 'mask' : undefined, onOpen: () => setOpen(true) })}
/>
{open && (
<CallTreeDialog
kind="function"
name="mask_phone"
where="utils/phone.py:28"
status="ready"
trees={[ONE]}
onClose={() => setOpen(false)}
/>
)}
</>
)
}
await render(<Page />)
const button = container.querySelector<HTMLButtonElement>('[data-call-tree="mask"]')!
await act(async () => button.click())
expect(container.querySelector('[data-id="mask"]')).not.toBeNull()
const dialog = document.body.querySelector('[role="dialog"]')
expect(dialog).not.toBeNull()
expect(dialog!.textContent).toContain('调用树')
expect(dialog!.textContent).toContain('mask_phone')
expect(dialog!.textContent).toContain('utils/phone.py:28')
expect(dialog!.textContent).not.toContain('读取用户资料')
expect(dialog!.querySelector('[role="tablist"]')).toBeNull()
await act(async () => dialog!.querySelector<HTMLButtonElement>('[aria-label="关闭"]')!.click())
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(container.querySelector('[data-id="mask"]')).not.toBeNull()
expect(document.activeElement).toBe(container.querySelector('[data-call-tree="mask"]'))
})
it('点遮罩或按 Esc 关掉弹框', async () => {
function Page(): ReactElement {
const [open, setOpen] = useState(true)
return open
? (
<>
<div data-id="mask">卡片</div>
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} />
</>
)
: <div data-id="mask">卡片</div>
}
await render(<Page key="overlay" />)
const overlay = document.body.querySelector<HTMLElement>('.cr-call-overlay')!
await act(async () => overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await render(<Page key="esc" />)
await act(async () => document.body.querySelector('.cr-call-dialog')!.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })))
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(document.body.textContent).toContain('卡片')
})
it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => {
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} onClose={() => {}} />)
const tabs = [...document.body.querySelectorAll('[role="tab"]')]
expect(tabs.map(tab => tab.textContent?.replace(/\s+/g, ''))).toEqual(['读取用户资料2处', '夜间对账1处'])
const body = () => document.body.querySelector('.cr-call-body')!.textContent ?? ''
expect(body()).toContain('另外 2 处调用')
expect(body()).not.toContain('夜间对账')
await act(async () => (tabs[1] as HTMLButtonElement).click())
expect(body()).toContain('夜间对账')
expect(body()).not.toContain('另外 2 处调用')
})
it('正在查、没有调用者,以及类的「新建」节点', async () => {
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} onClose={() => {}} />)
expect(document.body.textContent).toContain('正在查调用关系')
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} />)
expect(document.body.textContent).toContain('没有调用它的地方')
const made = node('新建 OrderService', {
kind: 'class',
detail: '进入这个类',
where: 'billing/order.py:40',
children: [node('OrderService', { kind: 'class', current: true, where: 'billing/order.py:14' })],
})
await render(
<CallTreeDialog
kind="class"
name="OrderService"
where="billing/order.py:14"
status="ready"
trees={[{ id: 'post', name: 'POST /orders', hits: 1, root: node('POST /orders', { detail: '入口', kind: 'function', children: [made] }) }]}
onClose={() => {}}
/>,
)
expect(document.body.textContent).toContain('新建 OrderService')
expect(document.body.textContent).toContain('进入这个类')
expect(document.body.textContent).toContain('当前')
expect(document.body.textContent).not.toContain('方法')
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
})
})
@@ -0,0 +1,120 @@
/**
* 调用树弹框:盖在编辑区上,预览的卡片留在后面。点遮罩、点叉、按 Esc 关掉,焦点回到打开它的按钮。
*/
import type { ReactElement } from 'react'
import type { CallTree, CallTreeNode } from './types'
import { Box, Braces, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
export function CallTreeDialog({ kind, name, where, status, trees, onClose }: {
/** 当前块的种类,整条路径用这个种类色 */
kind: string
name: string
where: string
status: 'loading' | 'empty' | 'ready'
trees: readonly CallTree[]
onClose: () => void
}): ReactElement {
const [selected, setSelected] = useState(0)
const tree = trees[Math.min(selected, Math.max(trees.length - 1, 0))]
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape')
return
document.querySelector<HTMLButtonElement>('[data-call-tree][data-active]')?.focus()
onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
function close(): void {
document.querySelector<HTMLButtonElement>('[data-call-tree][data-active]')?.focus()
onClose()
}
return createPortal(
<div
className="cr-call-overlay"
onClick={(event) => {
if (event.target === event.currentTarget)
close()
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={name ? `调用树 ${name}` : '调用树'}
className="cr-call-dialog"
data-kind={kind}
>
<div className="cr-call-title">
<span className="cr-call-heading">调用树</span>
{name && <span className="cr-call-symbol">{name}</span>}
{where && <span className="cr-call-loc">{where}</span>}
<button type="button" className="cr-tool cr-call-close" aria-label="关闭" onClick={close}>
<X size={16} strokeWidth={1.8} />
</button>
</div>
{status === 'ready' && trees.length > 1 && (
<div className="cr-call-entries" role="tablist" aria-label="调用树入口">
{trees.map((item, index) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={index === selected}
data-active={index === selected ? '' : undefined}
className="cr-call-entry"
onClick={() => setSelected(index)}
>
<span>{item.name}</span>
<span className="cr-call-hits">{`${item.hits} 处`}</span>
</button>
))}
</div>
)}
<div className="cr-call-body">
{status === 'loading' && <p className="cr-call-note">正在查调用关系</p>}
{status === 'empty' && <p className="cr-call-note">没有调用它的地方</p>}
{status === 'ready' && tree && <CallNodes node={tree.root} depth={0} path={tree.id} />}
</div>
</div>
</div>,
document.body,
)
}
function CallNodes({ node, depth, path }: { node: CallTreeNode, depth: number, path: string }): ReactElement {
const Icon = node.kind === 'class' ? Box : Braces
return (
<>
<div className="cr-call-row" style={{ paddingLeft: depth * 22 }}>
<span className="cr-call-guide" />
<div className="cr-call-card" data-current={node.current ? '' : undefined}>
<div className="cr-call-line">
<Icon size={14} strokeWidth={1.8} aria-hidden />
<span className="cr-call-name">{node.name}</span>
{node.current && <span className="cr-call-pill">当前</span>}
</div>
<div className="cr-call-sub">
{node.detail && <span>{node.detail}</span>}
<span className="cr-call-loc">{node.where}</span>
</div>
</div>
</div>
{node.children.map((child, index) => (
<CallNodes key={`${path}/${index}`} node={child} depth={depth + 1} path={`${path}/${index}`} />
))}
{node.otherCalls !== undefined && node.otherCalls > 0 && (
<div className="cr-call-row" style={{ paddingLeft: (depth + 1) * 22 }}>
<span className="cr-call-guide" data-folded="" />
<span className="cr-call-folded">{`另外 ${node.otherCalls} 处调用`}</span>
</div>
)}
</>
)
}
@@ -1281,3 +1281,179 @@ del.cr-diff-word {
white-space: pre;
text-overflow: ellipsis;
}
/* 调用树:盖住编辑区(文件树在预览外面,不会被压暗)。路径用当前块的种类色 */
.cr-call-count {
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
}
.cr-call-overlay {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 24px;
background: rgb(0 0 0 / 55%);
}
.cr-call-dialog {
display: flex;
flex-direction: column;
width: min(640px, 100%);
max-height: 100%;
overflow: hidden;
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent));
border-radius: 8px;
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-editorWidget-foreground, var(--vscode-foreground));
box-shadow: 0 8px 28px var(--vscode-widget-shadow);
}
.cr-call-title {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 14px;
border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent));
}
.cr-call-heading {
font-weight: 600;
}
.cr-call-symbol,
.cr-call-name {
color: var(--kind);
font-family: var(--vscode-editor-font-family, monospace);
font-weight: 600;
}
.cr-call-loc {
color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground));
font-family: var(--vscode-editor-font-family, monospace);
font-size: 11px;
}
.cr-call-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-left: auto;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--vscode-descriptionForeground);
}
.cr-call-entries {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 4px;
}
.cr-call-entry {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent));
border-radius: 6px;
background: var(--vscode-input-background, transparent);
color: var(--vscode-foreground);
font-size: 12px;
}
.cr-call-entry[aria-selected="true"] {
border-color: var(--vscode-focusBorder);
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
font-weight: 600;
}
.cr-call-hits {
color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground));
font-size: 11px;
font-weight: 400;
}
.cr-call-entry[aria-selected="true"] .cr-call-hits {
color: var(--vscode-list-activeSelectionForeground);
}
.cr-call-body {
min-height: 0;
padding: 12px 16px 16px;
overflow: auto;
}
.cr-call-note {
margin: 8px 0;
color: var(--vscode-descriptionForeground);
}
.cr-call-row {
display: flex;
align-items: stretch;
gap: 8px;
}
.cr-call-row + .cr-call-row {
margin-top: 8px;
}
.cr-call-guide {
flex-shrink: 0;
width: 2px;
border-radius: 1px;
background: var(--kind);
}
.cr-call-guide[data-folded] {
align-self: center;
height: 16px;
background: var(--vscode-editorIndentGuide-background1, var(--vscode-tree-inactiveIndentGuidesStroke));
}
.cr-call-card {
flex: 1;
min-width: 0;
padding: 7px 10px;
border: 1px solid color-mix(in srgb, var(--kind) 35%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--kind) 7%, transparent);
}
.cr-call-card[data-current] {
border-color: var(--kind);
background: color-mix(in srgb, var(--kind) 14%, transparent);
}
.cr-call-line,
.cr-call-sub {
display: flex;
align-items: center;
gap: 8px;
}
.cr-call-line {
color: var(--kind);
}
.cr-call-sub {
margin-top: 2px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.cr-call-pill {
display: flex;
flex-shrink: 0;
align-items: center;
height: 18px;
margin-left: auto;
padding: 0 6px;
border-radius: 9px;
background: var(--kind);
color: var(--vscode-editor-background);
font-size: 11px;
font-weight: 600;
}
.cr-call-folded {
align-self: center;
color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground));
font-size: 12px;
}
+27
View File
@@ -445,7 +445,30 @@ export interface StickyScrollSettings {
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
* callTreeCounts:version 这份文档里,每个函数、类落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理
* callTree:anchor 那块的调用树。trees 空着是没有调用它的地方。不是页面上这个版本的不理
*/
export interface CallTreeNode {
name: string
kind: string
/** 第二行。根是「入口」,新建类是「进入这个类」 */
detail?: string
/** 文件:行,行从 1 起 */
where: string
current: boolean
children: CallTreeNode[]
/** 不在通向当前块的路径上的调用次数 */
otherCalls?: number
}
/** 一棵调用树。hits 是这一棵里当前块出现几次。 */
export interface CallTree {
id: string
name: string
hits: number
root: CallTreeNode
}
export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
@@ -469,6 +492,8 @@ export type ExtensionToWebview
| { type: 'repoLinkOpened', path?: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' }
| { type: 'callTreeCounts', version: number, counts: Record<string, number> }
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[] }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
@@ -493,6 +518,7 @@ export type ExtensionToWebview
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
* openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -516,3 +542,4 @@ export type WebviewToExtension
| { type: 'openRepoLink', path?: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
| { type: 'openCallTree', version: number, anchor: string }