✨ feat(vscode): 代码阅读函数块默认折叠,函数说明进属性表「说明」行
This commit is contained in:
1 parent
62f48fe67c
commit
9b32d4b5a8
17 files changed
+489
-100
No files matched your search
@@ -9,17 +9,18 @@ import { BlockLevel } from './BlockTree'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
import { useReveal } from './useReveal'
|
||||
import { useScrollPosition } from './useScrollPosition'
|
||||
import { collapsedIds } from './viewState'
|
||||
import { onMessage, postMessage, viewState } from './vscode'
|
||||
|
||||
type BlockModes = Pick<ViewState, 'editing' | 'collapsed'>
|
||||
type BlockModes = Pick<ViewState, 'editing' | 'folds'>
|
||||
|
||||
function toggled(ids: string[], id: string): string[] {
|
||||
return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]
|
||||
}
|
||||
|
||||
function savedBlockModes(): BlockModes {
|
||||
const { editing, collapsed } = viewState.get()
|
||||
return { editing, collapsed }
|
||||
const { editing, folds } = viewState.get()
|
||||
return { editing, folds }
|
||||
}
|
||||
|
||||
export function App(): ReactElement | null {
|
||||
@@ -28,10 +29,16 @@ export function App(): ReactElement | null {
|
||||
const [focusId, setFocusId] = useState<string | null>(null)
|
||||
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
|
||||
const [reveal, setReveal] = useState<Reveal | null>(null)
|
||||
const expand = useCallback((ids: string[]) => setView(prev => ({ ...prev, collapsed: prev.collapsed.filter(id => !ids.includes(id)) })), [])
|
||||
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds])
|
||||
/** 记下这几块、属性行折叠还是展开,从此不再按默认 */
|
||||
const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
|
||||
...prev,
|
||||
folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) },
|
||||
})), [])
|
||||
const expand = useCallback((ids: string[]) => setFolded(ids, false), [setFolded])
|
||||
const revealed = useCallback(() => setReveal(null), [])
|
||||
useScrollPosition(doc)
|
||||
useReveal(doc, reveal, view.collapsed, expand, revealed)
|
||||
useReveal(doc, reveal, collapsed, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,15 +65,15 @@ export function App(): ReactElement | null {
|
||||
const tree = useMemo((): BlockTreeState | null => doc && {
|
||||
version: doc.version,
|
||||
editing: new Set(view.editing),
|
||||
collapsed: new Set(view.collapsed),
|
||||
collapsed,
|
||||
focusId,
|
||||
onFocused,
|
||||
onToggleEdit: (id) => {
|
||||
setFocusId(view.editing.includes(id) ? null : id)
|
||||
setView(prev => ({ ...prev, editing: toggled(prev.editing, id) }))
|
||||
},
|
||||
onToggleFold: id => setView(prev => ({ ...prev, collapsed: toggled(prev.collapsed, id) })),
|
||||
}, [doc, view, focusId, onFocused])
|
||||
onToggleFold: id => setFolded([id], !collapsed.has(id)),
|
||||
}, [doc, view, collapsed, focusId, onFocused, setFolded])
|
||||
|
||||
if (!doc || !tree)
|
||||
return null
|
||||
|
||||
@@ -29,7 +29,7 @@ const KIND_LABEL: Record<BlockKind, string> = {
|
||||
export interface BlockTreeState {
|
||||
version: number
|
||||
editing: ReadonlySet<string>
|
||||
/** 折叠的块 id 和属性行 id(propertyRowId),默认全部展开 */
|
||||
/** 眼下折叠着的块 id 和属性行 id(propertyRowId):用户的选择叠在默认上,见 collapsedIds */
|
||||
collapsed: ReadonlySet<string>
|
||||
/** 刚点了「编辑」、要把光标放进去的块 */
|
||||
focusId: string | null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BlockKind, BlockView, PropertyRow, Segment } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { revealPlan, scrollTopToShow } from './reveal'
|
||||
import { collapsedIds } from './viewState'
|
||||
|
||||
function name(text: string, offset: number): Segment {
|
||||
return { text, role: 'variable', offset }
|
||||
@@ -63,6 +64,14 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
|
||||
expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] })
|
||||
})
|
||||
|
||||
it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => {
|
||||
const plan = revealPlan(BLOCKS, 4, 102)
|
||||
const hidden = (folds: Record<string, boolean>): string[] => plan?.expand.filter(id => collapsedIds(BLOCKS, folds).has(id)) ?? []
|
||||
expect(hidden({})).toEqual(['1'])
|
||||
expect(hidden({ 1: false })).toEqual([])
|
||||
expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0'])
|
||||
})
|
||||
|
||||
it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
|
||||
const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])]
|
||||
expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] })
|
||||
|
||||
@@ -30,7 +30,7 @@ function flash(el: HTMLElement): void {
|
||||
export function useReveal(
|
||||
doc: DocumentMessage | null,
|
||||
reveal: Reveal | null,
|
||||
collapsed: readonly string[],
|
||||
collapsed: ReadonlySet<string>,
|
||||
expand: (ids: string[]) => void,
|
||||
done: () => void,
|
||||
): void {
|
||||
@@ -45,7 +45,7 @@ export function useReveal(
|
||||
return
|
||||
}
|
||||
const plan = revealPlan(doc.blocks, reveal.line, reveal.definition)
|
||||
const hidden = plan?.expand.filter(id => collapsed.includes(id)) ?? []
|
||||
const hidden = plan?.expand.filter(id => collapsed.has(id)) ?? []
|
||||
if (hidden.length > 0) {
|
||||
expand(hidden)
|
||||
return
|
||||
|
||||
@@ -1,38 +1,65 @@
|
||||
import type { ViewState, ViewStateStorage } from './viewState'
|
||||
import type { BlockKind, BlockView } from './types'
|
||||
import type { ViewStateStorage } from './viewState'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createViewStateStore, propertyRowId } from './viewState'
|
||||
import { collapsedIds, createViewStateStore, propertyRowId } from './viewState'
|
||||
|
||||
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回 */
|
||||
function memoryStorage(): ViewStateStorage {
|
||||
let saved: string | undefined
|
||||
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */
|
||||
function memoryStorage(initial?: unknown): ViewStateStorage {
|
||||
let saved = initial === undefined ? undefined : JSON.stringify(initial)
|
||||
return {
|
||||
getState: () => saved === undefined ? undefined : JSON.parse(saved) as ViewState,
|
||||
getState: () => saved === undefined ? undefined : JSON.parse(saved) as unknown,
|
||||
setState: (state) => {
|
||||
saved = JSON.stringify(state)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], children, translation: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 0 import
|
||||
* 1 function
|
||||
* 1.0 if
|
||||
* 1.0.0 function(嵌套函数)
|
||||
* 2 class
|
||||
* 2.0 function(方法)
|
||||
*/
|
||||
const BLOCKS = [
|
||||
block('0', 'import'),
|
||||
block('1', 'function', [block('1.0', 'if', [block('1.0.0', 'function', [block('1.0.0.0', 'return')])])]),
|
||||
block('2', 'class', [block('2.0', 'function', [block('2.0.0', 'return')])]),
|
||||
]
|
||||
|
||||
describe('webview 状态', () => {
|
||||
it('第一次打开:没有编辑中、折叠的块,也没有滚动位置', () => {
|
||||
expect(createViewStateStore(memoryStorage()).get()).toEqual({ editing: [], collapsed: [] })
|
||||
it('第一次打开:没有编辑中的块、没点过折叠,也没有滚动位置', () => {
|
||||
expect(createViewStateStore(memoryStorage()).get()).toEqual({ editing: [], folds: {} })
|
||||
})
|
||||
|
||||
it('块状态和滚动位置各自更新,互不覆盖', () => {
|
||||
const storage = memoryStorage()
|
||||
const store = createViewStateStore(storage)
|
||||
store.update({ editing: ['2'], collapsed: ['6.1'] })
|
||||
store.update({ editing: ['2'], folds: { 6.1: true } })
|
||||
store.update({ scroll: { line: 12, offset: 30, scrollTop: 480 } })
|
||||
store.update({ editing: [], collapsed: ['6.1'] })
|
||||
expect(storage.getState()).toEqual({ editing: [], collapsed: ['6.1'], scroll: { line: 12, offset: 30, scrollTop: 480 } })
|
||||
store.update({ editing: [], folds: { 6.1: true } })
|
||||
expect(storage.getState()).toEqual({ editing: [], folds: { 6.1: true }, scroll: { line: 12, offset: 30, scrollTop: 480 } })
|
||||
})
|
||||
|
||||
it('页面重新加载后取回上次存的全部状态', () => {
|
||||
const storage = memoryStorage()
|
||||
const store = createViewStateStore(storage)
|
||||
store.update({ collapsed: ['3'] })
|
||||
store.update({ folds: { 3: true, 4: false } })
|
||||
store.update({ scroll: { line: 40, offset: -6, scrollTop: 1210 } })
|
||||
expect(createViewStateStore(storage).get()).toEqual({ editing: [], collapsed: ['3'], scroll: { line: 40, offset: -6, scrollTop: 1210 } })
|
||||
expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: { 3: true, 4: false }, scroll: { line: 40, offset: -6, scrollTop: 1210 } })
|
||||
})
|
||||
|
||||
it('旧版本存的、形状不对的项当没存过,认得的项照旧取回', () => {
|
||||
expect(createViewStateStore(memoryStorage({ editing: ['1'], collapsed: ['2'] })).get()).toEqual({ editing: ['1'], folds: {} })
|
||||
expect(createViewStateStore(memoryStorage({ editing: 'x', folds: ['2'], scroll: { line: 'a' } })).get()).toEqual({ editing: [], folds: {} })
|
||||
expect(createViewStateStore(memoryStorage({ editing: [1, '2'], folds: { 1: true, 2: 'yes' } })).get()).toEqual({ editing: ['2'], folds: { 1: true } })
|
||||
expect(createViewStateStore(memoryStorage(42)).get()).toEqual({ editing: [], folds: {} })
|
||||
expect(createViewStateStore(memoryStorage(null)).get()).toEqual({ editing: [], folds: {} })
|
||||
})
|
||||
|
||||
it('属性行的折叠 id 是块 id 加行序号,不会和块 id 撞上', () => {
|
||||
@@ -41,9 +68,20 @@ describe('webview 状态', () => {
|
||||
expect(propertyRowId('6.1', 0)).not.toBe(propertyRowId('6', 10))
|
||||
})
|
||||
|
||||
it('属性行和块的折叠状态存在同一份 collapsed 里,页面重新加载后都还在', () => {
|
||||
it('属性行和块的折叠选择存在同一份 folds 里,页面重新加载后都还在', () => {
|
||||
const storage = memoryStorage()
|
||||
createViewStateStore(storage).update({ collapsed: ['6.1', propertyRowId('6.1.0', 3)] })
|
||||
expect(createViewStateStore(storage).get().collapsed).toEqual(['6.1', '6.1.0/p3'])
|
||||
createViewStateStore(storage).update({ folds: { 6.1: false, [propertyRowId('6.1.0', 3)]: true } })
|
||||
expect(createViewStateStore(storage).get().folds).toEqual({ '6.1': false, '6.1.0/p3': true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('眼下折叠着哪些', () => {
|
||||
it('没点过:函数块(含方法、嵌套函数)折叠,其余块和属性行展开', () => {
|
||||
expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '2.0'])
|
||||
})
|
||||
|
||||
it('点过的按用户的:展开的函数保持展开,折叠的其他块、属性行保持折叠', () => {
|
||||
const folds = { '1': false, '2': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false }
|
||||
expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '2', '2.0'])
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { ScrollAnchor } from './scrollPosition'
|
||||
import type { BlockKind, BlockView } from './types'
|
||||
|
||||
/**
|
||||
* 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、
|
||||
* 哪些块和属性行折叠了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行 id 见 propertyRowId),以及滚到了哪。
|
||||
* 用户把哪些块和属性行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行 id 见 propertyRowId),以及滚到了哪。
|
||||
*/
|
||||
export interface ViewState {
|
||||
editing: string[]
|
||||
collapsed: string[]
|
||||
/** 用户自己点过折叠箭头的块和属性行:id → 折叠着没有。没点过的按默认,见 collapsedIds */
|
||||
folds: Record<string, boolean>
|
||||
scroll?: ScrollAnchor
|
||||
}
|
||||
|
||||
export interface ViewStateStorage {
|
||||
getState: () => ViewState | undefined
|
||||
/** 存下来的东西可能是旧版本的形状,读的时候逐项核对 */
|
||||
getState: () => unknown
|
||||
setState: (state: ViewState) => void
|
||||
}
|
||||
|
||||
@@ -23,7 +26,7 @@ export interface ViewStateStore {
|
||||
|
||||
/** 一个 webview 只有一份状态:块的状态和滚动位置由不同地方各自更新,存的时候合在一起。 */
|
||||
export function createViewStateStore(storage: ViewStateStorage): ViewStateStore {
|
||||
let current: ViewState = storage.getState() ?? { editing: [], collapsed: [] }
|
||||
let current = readViewState(storage.getState())
|
||||
return {
|
||||
get: () => current,
|
||||
update: (patch) => {
|
||||
@@ -33,8 +36,45 @@ export function createViewStateStore(storage: ViewStateStorage): ViewStateStore
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isScrollAnchor(value: unknown): value is ScrollAnchor {
|
||||
return isRecord(value) && [value.line, value.offset, value.scrollTop].every(field => typeof field === 'number')
|
||||
}
|
||||
|
||||
/** 存下来的状态逐项核对,形状不对的项(包括旧版本存的)当没存过。 */
|
||||
function readViewState(saved: unknown): ViewState {
|
||||
const state = isRecord(saved) ? saved : {}
|
||||
const editing = Array.isArray(state.editing) ? state.editing.filter((id): id is string => typeof id === 'string') : []
|
||||
const folds = isRecord(state.folds)
|
||||
? Object.fromEntries(Object.entries(state.folds).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
|
||||
: {}
|
||||
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
|
||||
}
|
||||
|
||||
/** 块默认折叠吗:函数块收起主体(属性表照常显示),其余块展开。属性行默认都展开。 */
|
||||
export function foldedByDefault(kind: BlockKind): boolean {
|
||||
return kind === 'function'
|
||||
}
|
||||
|
||||
/** 眼下折叠着的块和属性行:用户点过的按他点的,没点过的按 foldedByDefault。 */
|
||||
export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Record<string, boolean>>): Set<string> {
|
||||
const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
|
||||
const visit = (list: readonly BlockView[]): void => {
|
||||
for (const block of list) {
|
||||
if (folds[block.id] === undefined && foldedByDefault(block.kind))
|
||||
collapsed.add(block.id)
|
||||
visit(block.children)
|
||||
}
|
||||
}
|
||||
visit(blocks)
|
||||
return collapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性表里带子行的那一行(输入参数、导入项)的折叠 id,和块 id 放在同一个 collapsed 里:
|
||||
* 属性表里带子行的那一行(输入参数、导入项)的折叠 id,和块 id 放在同一份 folds 里:
|
||||
* `<块 id>/p<行序号>`,例如 '6.1.0/p3'。块 id 只由数字和点组成,带 '/' 的不会和任何块 id 撞上。
|
||||
*/
|
||||
export function propertyRowId(blockId: string, rowIndex: number): string {
|
||||
|
||||
Reference in new issue
Block a user