🐛 fix(vscode): 切走标签再回来,调用树棵数不用重算,开着的弹框和选中的入口照旧

This commit is contained in:
cruldra committed 2026-10-13 11:00:06 +11:00
1 parent 6cf053dcd0
commit 32018e71f1
9 files changed
+542 -110

No files matched your search

@@ -0,0 +1,158 @@
// @vitest-environment happy-dom
import type { Root } from 'react-dom/client'
import type { BlockView, CallTree, DocumentMessage, ExtensionToWebview, WebviewToExtension } from './types'
import type { ViewState, ViewStateStore } from './viewState'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { App } from './App'
import { createViewStateStore } from './viewState'
/** 扩展端那头:页面发出去的消息、页面挂着的收消息回调,以及 webview 自己存的状态(标签页切回来页面重新加载时还在) */
const host = vi.hoisted(() => ({
posted: [] as unknown[],
listeners: new Set<(msg: unknown) => void>(),
store: undefined as ViewStateStore | undefined,
}))
vi.mock('./vscode', () => ({
postMessage: (msg: WebviewToExtension) => host.posted.push(msg),
onMessage: (handler: (msg: unknown) => void) => {
host.listeners.add(handler)
return () => host.listeners.delete(handler)
},
viewState: {
get: () => host.store!.get(),
update: (patch: Partial<ViewState>) => host.store!.update(patch),
},
}))
let container: HTMLDivElement
let root: Root
let saved: ViewState | undefined
/** 页面(重新)加载:从 webview 存的状态起步 */
async function load(): Promise<void> {
host.store = createViewStateStore({
getState: () => saved,
setState: (state) => {
saved = JSON.parse(JSON.stringify(state)) as ViewState
},
})
host.posted.length = 0
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
await act(async () => root.render(<App />))
}
async function unload(): Promise<void> {
await act(async () => root.unmount())
container.remove()
host.listeners.clear()
}
async function deliver(msg: ExtensionToWebview): Promise<void> {
await act(async () => {
for (const listener of host.listeners)
listener(msg)
})
}
function block(id: string, anchor: string): BlockView {
return {
id,
anchor,
kind: 'function',
callTree: {},
startLine: 0,
endLine: 1,
code: '',
codeTokens: [],
attributes: { kind: 'function' },
concepts: [{ id: 'function', label: 'function', icon: 'braces' }],
children: [],
translation: [{ text: anchor, role: 'plain' }],
}
}
function documentOf(version: number, blocks: BlockView[]): DocumentMessage {
return { type: 'document', version, languageId: 'python', blocks, attributes: [], translators: [], translatorId: '', names: [] }
}
function tree(id: string, name: string): CallTree {
return { id, name, hits: 1, root: { name, kind: 'function', where: `${name}.py:1`, jump: { uri: `file:///${name}.py`, line: 0, character: 0 }, current: false, children: [] } }
}
const TREES = [tree('read', '读取用户资料'), tree('night', '夜间对账')]
const dialog = () => document.body.querySelector('[role="dialog"]')
const selectedTab = () => document.body.querySelector('[role="tab"][aria-selected="true"]')?.textContent?.replace(/\s+/g, '')
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
saved = undefined
})
afterEach(async () => {
await unload()
vi.unstubAllGlobals()
})
describe('标签页切走再切回来,调用树弹框照旧开着', () => {
it('弹框开着、选了第二个入口:页面重新加载拿到文档后重新打开那块、要它的树,停回第二个入口;关掉后再加载就不开了', async () => {
await load()
await deliver(documentOf(3, [block('0', 'mask_phone')]))
await act(async () => document.body.querySelector<HTMLButtonElement>('[data-call-tree="mask_phone"]')!.click())
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES })
await act(async () => document.body.querySelectorAll<HTMLButtonElement>('[role="tab"]')[1].click())
expect(saved?.callTree).toEqual({ anchor: 'mask_phone', entry: 1 })
await unload()
await load()
expect(dialog()).toBeNull()
await deliver(documentOf(3, [block('0', 'mask_phone')]))
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
expect(dialog()!.textContent).toContain('正在查调用关系')
await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES })
expect(selectedTab()).toBe('夜间对账1处')
await act(async () => dialog()!.querySelector<HTMLButtonElement>('[aria-label="关闭"]')!.click())
expect(saved?.callTree).toBeUndefined()
await unload()
await load()
await deliver(documentOf(3, [block('0', 'mask_phone')]))
expect(dialog()).toBeNull()
expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' }))
})
it('页面重新加载前切走、文档还没到又切走一次,记着的弹框还在', async () => {
saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } }
await load()
await unload()
await load()
await deliver(documentOf(3, [block('0', 'mask_phone')]))
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
expect(dialog()).not.toBeNull()
})
it('那块已经不在了:不开弹框、不要树,记录丢掉', async () => {
saved = { editing: [], folds: {}, callTree: { anchor: 'gone', entry: 0 } }
await load()
await deliver(documentOf(4, [block('0', 'mask_phone')]))
expect(dialog()).toBeNull()
expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' }))
expect(saved?.callTree).toBeUndefined()
})
it('弹框开着时文档换了一版:照旧关掉,记录跟着清掉', async () => {
saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 0 } }
await load()
await deliver(documentOf(5, [block('0', 'mask_phone')]))
expect(dialog()).not.toBeNull()
await deliver(documentOf(6, [block('0', 'mask_phone')]))
expect(dialog()).toBeNull()
expect(saved?.callTree).toBeUndefined()
})
})
+47 -17
View File
@@ -43,6 +43,28 @@ function savedBlockModes(): BlockModes {
return { editing, folds }
}
/** 开着的调用树弹框:哪一版文档的哪一块、选中第几个入口,扩展端发来的树 */
interface OpenCall {
anchor: string
version: number
entry: number
status: 'loading' | 'ready'
name: string
where: string
kind: string
badge?: KindBadge
trees: CallTree[]
contractMiss?: ContractMiss
}
/** 打开 doc 里 anchor 那块的调用树弹框,先是「正在查」,等扩展端发树来;这块不在了、或者它看不了调用树时是 undefined。 */
function openingCall(doc: DocumentMessage, anchor: string, entry: number): OpenCall | undefined {
const block = blockAt(doc.blocks, anchor)
if (!block?.callTree)
return undefined
return { anchor, version: doc.version, entry, status: 'loading', name: '', where: '', kind: block.kind, ...(block.badge && { badge: block.badge }), trees: [] }
}
function blockAt(blocks: readonly BlockView[], anchor: string): BlockView | undefined {
for (const block of blocks) {
if (block.anchor === anchor)
@@ -79,17 +101,9 @@ export function App(): ReactElement | null {
/** 页面上这份文档的版本:调用树的回答不是这一版就丢掉 */
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
badge?: KindBadge
trees: CallTree[]
contractMiss?: ContractMiss
} | null>(null)
const [openCall, setOpenCall] = useState<OpenCall | null>(null)
/** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */
const restoreCall = useRef(viewState.get().callTree)
/** 块树所在的页面内容:钉住卡片时在这里面量 */
const content = useRef<HTMLDivElement>(null)
// 解析失败的文档没有块,不标差异;删掉的块按块筛选同样藏
@@ -138,16 +152,24 @@ export function App(): ReactElement | null {
useEffect(() => {
const off = onMessage((msg) => {
switch (msg.type) {
case 'document':
case 'document': {
const restoring = restoreCall.current
restoreCall.current = undefined
const reopened = restoring && openingCall(msg, restoring.anchor, restoring.entry)
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)
setOpenCall(prev => prev?.version === msg.version ? prev : reopened ?? null)
if (reopened)
postMessage({ type: 'openCallTree', version: msg.version, anchor: reopened.anchor })
else if (restoring)
viewState.update({ callTree: undefined })
if (msg.reveal)
setReveal(msg.reveal)
return
}
case 'callTreeCounts':
if (msg.version !== docVersion.current)
return
@@ -195,13 +217,19 @@ export function App(): ReactElement | null {
editing.current = view.editing
}, [view])
useEffect(() => {
if (restoreCall.current)
return
viewState.update({ callTree: openCall ? { anchor: openCall.anchor, entry: openCall.entry } : undefined })
}, [openCall])
const onFocused = useCallback(() => setFocusId(null), [])
const onOpenCall = useCallback((anchor: string) => {
if (!doc)
const opening = doc && openingCall(doc, anchor, 0)
if (!opening)
return
const block = blockAt(doc.blocks, anchor)
setOpenCall({ anchor, version: doc.version, status: 'loading', name: '', where: '', kind: block?.kind ?? 'function', ...(block?.badge && { badge: block.badge }), trees: [] })
postMessage({ type: 'openCallTree', version: doc.version, anchor })
setOpenCall(opening)
postMessage({ type: 'openCallTree', version: opening.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])
@@ -315,7 +343,9 @@ export function App(): ReactElement | null {
where={openCall.where}
status={openCall.status === 'loading' ? 'loading' : openCall.trees.length === 0 ? 'empty' : 'ready'}
trees={openCall.trees}
selected={openCall.entry}
contractMiss={openCall.contractMiss}
onSelect={entry => setOpenCall(prev => prev && { ...prev, entry })}
onClose={() => setOpenCall(null)}
onJump={(jump) => {
setOpenCall(null)
@@ -140,6 +140,8 @@ describe('调用树弹框', () => {
where="utils/phone.py:28"
status="ready"
trees={[ONE]}
selected={0}
onSelect={() => {}}
onClose={() => setOpen(false)}
onJump={() => {}}
/>
@@ -172,7 +174,7 @@ describe('调用树弹框', () => {
? (
<>
<div data-id="mask">卡片</div>
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} onJump={() => {}} />
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} selected={0} onSelect={() => {}} onClose={() => setOpen(false)} onJump={() => {}} />
</>
)
: <div data-id="mask">卡片</div>
@@ -191,21 +193,51 @@ describe('调用树弹框', () => {
})
it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => {
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} onClose={() => {}} onJump={() => {}} />)
const picked: number[] = []
function Page(): ReactElement {
const [selected, setSelected] = useState(0)
return (
<CallTreeDialog
kind="function"
name="mask_phone"
where="utils/phone.py:28"
status="ready"
trees={MANY}
selected={selected}
onSelect={(index) => {
picked.push(index)
setSelected(index)
}}
onClose={() => {}}
onJump={() => {}}
/>
)
}
await render(<Page />)
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(picked).toEqual([1])
expect(body()).toContain('夜间对账')
expect(body()).not.toContain('另外 2 处调用')
})
it('选中第几个入口由外面给(页面重新加载后按记下的停回去);记下的比眼下的树多时停在最后一棵', async () => {
const selectedTab = () => document.body.querySelector('[role="tab"][aria-selected="true"]')?.textContent?.replace(/\s+/g, '')
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} selected={1} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
expect(selectedTab()).toBe('夜间对账1处')
expect(document.body.querySelector('.cr-call-body')!.textContent).not.toContain('另外 2 处调用')
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} selected={5} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
expect(selectedTab()).toBe('夜间对账1处')
})
it('正在查、没有调用者,以及类的「新建」节点', async () => {
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} onClose={() => {}} onJump={() => {}} />)
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
expect(document.body.textContent).toContain('正在查调用关系')
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} onJump={() => {}} />)
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
expect(document.body.textContent).toContain('没有调用它的地方')
const made = node('新建 OrderService', {
kind: 'class',
@@ -220,6 +252,8 @@ describe('调用树弹框', () => {
where="billing/order.py:14"
status="ready"
trees={[{ id: 'post', name: 'POST /orders', hits: 1, root: node('POST /orders', { detail: '入口', kind: 'function', children: [made] }) }]}
selected={0}
onSelect={() => {}}
onClose={() => {}}
onJump={() => {}}
/>,
@@ -231,25 +265,25 @@ describe('调用树弹框', () => {
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
})
it('接口在契约文件里没对上:写明重新生成、列出找到的契约文件;一份都没有时写没找到;都不说「没有调用它的地方」', async () => {
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: ['web/src/lib/api/schema.d.ts', 'admin/src/lib/api/schema.d.ts'] }} onClose={() => {}} onJump={() => {}} />)
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: ['web/src/lib/api/schema.d.ts', 'admin/src/lib/api/schema.d.ts'] }} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
const body = () => document.body.querySelector('.cr-call-body')!.textContent ?? ''
expect(body()).toContain('接口契约文件里没有这个接口,重新生成后再看')
expect(body()).toContain('web/src/lib/api/schema.d.ts')
expect(body()).toContain('admin/src/lib/api/schema.d.ts')
expect(body()).not.toContain('没有调用它的地方')
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: [] }} onClose={() => {}} onJump={() => {}} />)
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: [] }} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
expect(body()).toContain('工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)')
expect(body()).not.toContain('没有调用它的地方')
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="ready" trees={[ONE]} contractMiss={{ files: [] }} onClose={() => {}} onJump={() => {}} />)
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="ready" trees={[ONE]} contractMiss={{ files: [] }} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
expect(body()).toContain('工作区里没找到接口契约文件')
expect(body()).toContain('夜间对账')
})
it('契约里几条都对得上、分不出是哪一条时列出那几条,不叫人重新生成', async () => {
const miss = { files: ['web/src/lib/api/schema.d.ts'], ambiguous: ['/api/v1/admin/agents', '/api/v1/agents'] }
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={miss} onClose={() => {}} onJump={() => {}} />)
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={miss} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
const body = document.body.querySelector('.cr-call-body')!.textContent ?? ''
expect(body).toContain('接口契约文件里有几条都对得上这个接口,按 operationId 也分不出是哪一条')
expect(body).toContain('/api/v1/admin/agents')
@@ -267,7 +301,7 @@ describe('调用树弹框', () => {
children: [node('get_default_agent', { current: true })],
})
const page: CallTree = { id: 'page', name: 'MyAgentsPage', hits: 1, root: node('MyAgentsPage', { detail: '页面 /my-agents', children: [request] }) }
await render(<CallTreeDialog kind="fastapi.route" name="get_default_agent" where="api/routes.py:79" status="ready" trees={[page]} onClose={() => {}} onJump={jump => jumps.push(jump)} />)
await render(<CallTreeDialog kind="fastapi.route" name="get_default_agent" where="api/routes.py:79" status="ready" trees={[page]} selected={0} onSelect={() => {}} onClose={() => {}} onJump={jump => jumps.push(jump)} />)
expect(document.body.textContent).toContain('页面 /my-agents')
const rows = [...document.body.querySelectorAll<HTMLButtonElement>('button.cr-call-card')]
expect(rows.map(row => row.querySelector('.cr-call-name')!.textContent)).toEqual(['MyAgentsPage', '请求 GET /api/v1/agents/default', 'get_default_agent'])
@@ -282,7 +316,7 @@ describe('调用树弹框', () => {
where,
children: [node('mask_phone', { current: true, where: 'phone.py:28' })],
})
await render(<CallTreeDialog kind="function" name="run" where={where} status="ready" trees={[{ id: 'run', name: 'run', hits: 1, root: run }]} onClose={() => {}} onJump={() => {}} />)
await render(<CallTreeDialog kind="function" name="run" where={where} status="ready" trees={[{ id: 'run', name: 'run', hits: 1, root: run }]} selected={0} onSelect={() => {}} onClose={() => {}} onJump={() => {}} />)
const title = document.body.querySelector('.cr-call-title')!
const place = title.querySelector<HTMLElement>('[data-tooltip]')!
expect(place.dataset.tooltip).toBe(where)
@@ -6,11 +6,11 @@
import type { ReactElement } from 'react'
import type { CallJump, CallTree, CallTreeNode, ContractMiss, KindBadge } from './types'
import { Box, Braces, Send, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { kindProps } from './BlockTree'
export function CallTreeDialog({ kind, badge, name, where, status, trees, contractMiss, onClose, onJump }: {
export function CallTreeDialog({ kind, badge, name, where, status, trees, selected, contractMiss, onSelect, onClose, onJump }: {
/** 当前块的种类,整条路径用这个种类色;库规则的卡片按 badge 的颜色 */
kind: string
badge?: KindBadge
@@ -18,14 +18,18 @@ export function CallTreeDialog({ kind, badge, name, where, status, trees, contra
where: string
status: 'loading' | 'empty' | 'ready'
trees: readonly CallTree[]
/** 选中第几个入口(从 0 起),页面记着它,重新加载后停回去;比眼下的树多时停在最后一棵 */
selected: number
/** 接口在契约文件里没对上时有 */
contractMiss?: ContractMiss
/** 点了另一个入口 */
onSelect: (index: number) => void
onClose: () => void
/** 点了树上的一行 */
onJump: (jump: CallJump) => void
}): ReactElement {
const [selected, setSelected] = useState(0)
const tree = trees[Math.min(selected, Math.max(trees.length - 1, 0))]
const current = Math.min(selected, Math.max(trees.length - 1, 0))
const tree = trees[current]
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
@@ -73,10 +77,10 @@ export function CallTreeDialog({ kind, badge, name, where, status, trees, contra
key={item.id}
type="button"
role="tab"
aria-selected={index === selected}
data-active={index === selected ? '' : undefined}
aria-selected={index === current}
data-active={index === current ? '' : undefined}
className="cr-call-entry"
onClick={() => setSelected(index)}
onClick={() => onSelect(index)}
>
<span>{item.name}</span>
<span className="cr-call-hits">{`${item.hits} 处`}</span>
@@ -70,6 +70,17 @@ describe('webview 状态', () => {
expect(createViewStateStore(memoryStorage({ openedFrom: 'files' })).get()).toEqual({ editing: [], folds: {} })
})
it('开着的调用树弹框(哪一块、选中第几个入口)页面重新加载后取回,关掉后就没了;形状不对的当没记过', () => {
const storage = memoryStorage()
const store = createViewStateStore(storage)
store.update({ callTree: { anchor: 'mask_phone', entry: 1 } })
expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } })
store.update({ callTree: undefined })
expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {} })
for (const callTree of ['mask_phone', { anchor: 1, entry: 0 }, { anchor: 'mask_phone' }, { anchor: 'mask_phone', entry: -1 }, { anchor: 'mask_phone', entry: 1.5 }])
expect(createViewStateStore(memoryStorage({ callTree })).get()).toEqual({ editing: [], folds: {} })
})
it('属性行的折叠 id 是块 id 加行序号,不会和块 id 撞上', () => {
expect(propertyRowId('6.1.0', 3)).toBe('6.1.0/p3')
expect(propertyRowId('6', 1)).not.toBe('6.1')
+15 -1
View File
@@ -3,7 +3,8 @@ import type { BlockView, OpenedFrom } from './types'
/**
* 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、
* 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 [propertyRowId]、[propertyItemId]),以及滚到了哪。
* 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 [propertyRowId]、[propertyItemId]),滚到了哪,
* 以及开着哪一块的调用树弹框、选中第几个入口。
* VS Code 重启后恢复标签页时也带着它,这个标签页是从哪儿打开的靠它报回扩展端。
*/
export interface ViewState {
@@ -13,6 +14,14 @@ export interface ViewState {
scroll?: ScrollAnchor
/** 这个标签页是从哪儿打开的,扩展端告诉的;新开的页面还没记 */
openedFrom?: OpenedFrom
/** 开着的调用树弹框;关着是没有 */
callTree?: CallTreeView
}
/** 开着的调用树弹框:哪一块(anchor),选中第几个入口(从 0 起)。 */
export interface CallTreeView {
anchor: string
entry: number
}
export interface ViewStateStorage {
@@ -59,9 +68,14 @@ function readViewState(saved: unknown): ViewState {
folds,
...(isScrollAnchor(state.scroll) && { scroll: state.scroll }),
...((state.openedFrom === 'uncommitted' || state.openedFrom === 'other') && { openedFrom: state.openedFrom }),
...(isCallTreeView(state.callTree) && { callTree: state.callTree }),
}
}
function isCallTreeView(value: unknown): value is CallTreeView {
return isRecord(value) && typeof value.anchor === 'string' && Number.isInteger(value.entry) && (value.entry as number) >= 0
}
/**
* 块默认折叠吗:函数、类、枚举、异常块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。
* 挂在父卡片子行上、点子行才展开的子块(BlockSlot)默认收着:它折叠着就是整张不画。