✨ feat(vscode): 代码阅读库规则卡片不再吞掉字段中间的方法,编辑时收起子块
This commit is contained in:
1 parent
c803fd457f
commit
2251e67ef8
15 files changed
+280
-54
No files matched your search
@@ -5,6 +5,7 @@ import type { ViewState } from './viewState'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BlockLevel } from './BlockTree'
|
||||
import { coveredByEditing, startEditing } from './editing'
|
||||
import { applyFilter, keptWhileEditing } from './filter'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
@@ -18,10 +19,6 @@ type BlockModes = Pick<ViewState, 'editing' | 'folds'>
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set()
|
||||
|
||||
function toggled(ids: string[], id: string): string[] {
|
||||
return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]
|
||||
}
|
||||
|
||||
function savedBlockModes(): BlockModes {
|
||||
const { editing, folds } = viewState.get()
|
||||
return { editing, folds }
|
||||
@@ -41,6 +38,7 @@ export function App(): ReactElement | null {
|
||||
const editing = useRef(view.editing)
|
||||
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds])
|
||||
const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep])
|
||||
const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing])
|
||||
/** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */
|
||||
const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
|
||||
...prev,
|
||||
@@ -50,7 +48,7 @@ export function App(): ReactElement | null {
|
||||
const show = useCallback((ids: string[]) => setKeep(prev => new Set([...prev, ...ids])), [])
|
||||
const revealed = useCallback(() => setReveal(null), [])
|
||||
useScrollPosition(doc)
|
||||
useReveal(doc, reveal, collapsed, filter.hidden, show, expand, revealed)
|
||||
useReveal(doc, reveal, collapsed, filter.hidden, covered, show, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
const highlight = useOccurrences(doc?.version, doc?.translatorId)
|
||||
|
||||
@@ -88,15 +86,19 @@ export function App(): ReactElement | null {
|
||||
collapsed,
|
||||
hidden: filter.hidden,
|
||||
temporary: filter.temporary,
|
||||
covered,
|
||||
kindName: kind => kindNames.get(kind) ?? kind,
|
||||
focusId,
|
||||
onFocused,
|
||||
onToggleEdit: (id) => {
|
||||
setFocusId(view.editing.includes(id) ? null : id)
|
||||
setView(prev => ({ ...prev, editing: toggled(prev.editing, 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)),
|
||||
}, [doc, view, collapsed, filter, kindNames, focusId, onFocused, setFolded])
|
||||
}, [doc, view, collapsed, filter, covered, kindNames, focusId, onFocused, setFolded])
|
||||
|
||||
if (!doc || !tree)
|
||||
return null
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface BlockTreeState {
|
||||
hidden: ReadonlySet<string>
|
||||
/** 本来被筛掉、跳到定义时临时显示的块 */
|
||||
temporary: ReadonlySet<string>
|
||||
/** 被正在编辑的祖先盖住、不画的块(它们的行在祖先的编辑范围里),见 coveredByEditing */
|
||||
covered: ReadonlyMap<string, string>
|
||||
/** 种类标签上写什么:翻译插件给种类起的名字(库规则的种类是规则给的标签),没给就是 id */
|
||||
kindName: (kind: string) => string
|
||||
/** 刚点了「编辑」、要把光标放进去的块 */
|
||||
@@ -25,9 +27,14 @@ export interface BlockTreeState {
|
||||
onToggleFold: (id: string) => void
|
||||
}
|
||||
|
||||
/** 块下面有没有画出来的子块:子块都被筛掉时不留空的一层、不给折叠箭头。 */
|
||||
/** 块下面有没有画出来的子块:子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
|
||||
function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
|
||||
return block.children.some(child => !tree.hidden.has(child.id))
|
||||
return block.children.some(child => shown(child, tree))
|
||||
}
|
||||
|
||||
/** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */
|
||||
function shown(block: BlockView, tree: BlockTreeState): boolean {
|
||||
return !tree.hidden.has(block.id) && !tree.covered.has(block.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +51,7 @@ function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-
|
||||
}
|
||||
|
||||
/**
|
||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。
|
||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
|
||||
* owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。
|
||||
*/
|
||||
export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement {
|
||||
@@ -54,7 +61,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
|
||||
<div className="cr-guide w-px" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{blocks.filter(block => !tree.hidden.has(block.id)).map(block => (
|
||||
{blocks.filter(block => shown(block, tree)).map(block => (
|
||||
<Fragment key={block.id}>
|
||||
<BlockCard block={block} tree={tree} />
|
||||
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BlockView } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { coveredByEditing, startEditing } from './editing'
|
||||
import { revealPlan, uncoveredPlan } from './reveal'
|
||||
|
||||
function block(id: string, kind: string, [startLine, endLine]: [number, number], children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine, endLine, code: '', codeTokens: [], attributes: { kind }, children, properties: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 库规则的卡片(1)编辑范围是整个类(2–12 行),两个字段中间写着的方法(1.0)和它的主体(1.0.0)、类末尾的方法(1.1)都在这段里;
|
||||
* 普通的类(2)只编辑头部,方法(2.0)不在头部里;一行写完的 if(3)和它的返回(3.0)在同一行。
|
||||
*/
|
||||
const BLOCKS = [
|
||||
block('0', 'import', [0, 0]),
|
||||
block('1', 'sqlalchemy.table', [2, 12], [
|
||||
block('1.0', 'function', [5, 5], [block('1.0.0', 'return', [6, 6])]),
|
||||
block('1.1', 'function', [11, 12]),
|
||||
]),
|
||||
block('2', 'class', [14, 14], [block('2.0', 'function', [15, 16])]),
|
||||
block('3', 'if', [18, 18], [block('3.0', 'return', [18, 18])]),
|
||||
]
|
||||
|
||||
describe('同一行不会同时在两处能改', () => {
|
||||
it('卡片编辑时,它留下的子块(字段中间的方法连同主体)不画,跳到它们时改闪卡片', () => {
|
||||
expect(coveredByEditing(BLOCKS, new Set(['1']))).toEqual(new Map([['1.0', '1'], ['1.0.0', '1'], ['1.1', '1']]))
|
||||
})
|
||||
|
||||
it('只编辑头部的块不盖住子块;一行写完的块编辑时盖住同一行的子块', () => {
|
||||
expect(coveredByEditing(BLOCKS, new Set(['2', '2.0']))).toEqual(new Map())
|
||||
expect(coveredByEditing(BLOCKS, new Set(['3']))).toEqual(new Map([['3.0', '3']]))
|
||||
})
|
||||
|
||||
it('没在编辑的卡片不盖住子块:子块照常画、照常能单独编辑', () => {
|
||||
expect(coveredByEditing(BLOCKS, new Set(['1.0']))).toEqual(new Map())
|
||||
})
|
||||
|
||||
it('点「编辑」:编辑范围和它重叠的别的块退出编辑,不重叠的留着', () => {
|
||||
expect(startEditing(BLOCKS, ['1.0', '1.1', '2'], '1')).toEqual(['2', '1'])
|
||||
expect(startEditing(BLOCKS, ['1', '2'], '1.0')).toEqual(['2', '1.0'])
|
||||
expect(startEditing(BLOCKS, ['2'], '2.0')).toEqual(['2', '2.0'])
|
||||
})
|
||||
|
||||
it('任何时候正在编辑、画出来的几块,编辑范围两两不重叠', () => {
|
||||
const all = (list: BlockView[]): BlockView[] => list.flatMap(item => [item, ...all(item.children)])
|
||||
let editing: string[] = []
|
||||
for (const id of ['1.0', '1.0.0', '2', '2.0', '1', '3.0', '3', '1.1'])
|
||||
editing = startEditing(BLOCKS, editing, id)
|
||||
const covered = coveredByEditing(BLOCKS, new Set(editing))
|
||||
const shown = all(BLOCKS).filter(item => editing.includes(item.id) && !covered.has(item.id))
|
||||
for (const a of shown) {
|
||||
for (const b of shown.filter(other => other !== a))
|
||||
expect(a.startLine > b.endLine || b.startLine > a.endLine, `${a.id} / ${b.id}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('跳到定义的目标被盖住:闪盖住它的那块,展开那块的祖先', () => {
|
||||
const covered = coveredByEditing(BLOCKS, new Set(['1']))
|
||||
expect(uncoveredPlan({ id: '1.0.0', expand: ['1', '1.0'] }, covered)).toEqual({ id: '1', expand: [] })
|
||||
const plain = revealPlan(BLOCKS, 15, 999)
|
||||
expect(plain && uncoveredPlan(plain, covered)).toEqual(plain)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { BlockView } from './types'
|
||||
|
||||
/**
|
||||
* 同一时刻能编辑的几段源码不重叠,同一行不会同时在两处能改:
|
||||
* ① 块编辑时,编辑范围和它重叠的子孙块不画(库规则的卡片编辑整个类,留下的方法也在这段里;
|
||||
* 一行写完的 `if x: return 1`,子块和头部在同一行),它们下面的子孙也跟着不画;
|
||||
* ② 点「编辑」时,编辑范围和这块重叠的别的块退出编辑。
|
||||
*/
|
||||
|
||||
type Lines = Pick<BlockView, 'startLine' | 'endLine'>
|
||||
|
||||
function overlaps(a: Lines, b: Lines): boolean {
|
||||
return a.startLine <= b.endLine && b.startLine <= a.endLine
|
||||
}
|
||||
|
||||
/** 正在编辑的块盖住、不画的块 → 盖住它的那块(页面上画着的、正在编辑的祖先):跳到定义落在被盖住的块上时改闪它。 */
|
||||
export function coveredByEditing(blocks: readonly BlockView[], editing: ReadonlySet<string>): Map<string, string> {
|
||||
const covered = new Map<string, string>()
|
||||
const visit = (list: readonly BlockView[], editors: readonly BlockView[], owner: string | undefined): void => {
|
||||
for (const block of list) {
|
||||
const by = owner ?? editors.find(editor => overlaps(editor, block))?.id
|
||||
if (by !== undefined) {
|
||||
covered.set(block.id, by)
|
||||
visit(block.children, editors, by)
|
||||
}
|
||||
else {
|
||||
visit(block.children, editing.has(block.id) ? [...editors, block] : editors, undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(blocks, [], undefined)
|
||||
return covered
|
||||
}
|
||||
|
||||
function* allBlocks(blocks: readonly BlockView[]): Generator<BlockView> {
|
||||
for (const block of blocks) {
|
||||
yield block
|
||||
yield* allBlocks(block.children)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点了 id 这块的「编辑」之后正在编辑的块:加上它,编辑范围和它重叠的别的块退出编辑。 */
|
||||
export function startEditing(blocks: readonly BlockView[], editing: readonly string[], id: string): string[] {
|
||||
const byId = new Map([...allBlocks(blocks)].map(block => [block.id, block]))
|
||||
const target = byId.get(id)
|
||||
const others = editing.filter((other) => {
|
||||
const block = byId.get(other)
|
||||
return other !== id && !(target && block && overlaps(target, block))
|
||||
})
|
||||
return [...others, id]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BlockView, PreviewText, PropertyItem, SourceRange } from './types'
|
||||
import { blockPath } from './filter'
|
||||
import { showsOffset } from './segments'
|
||||
import { propertyItemId, propertyRowId } from './viewState'
|
||||
|
||||
@@ -109,6 +110,15 @@ export function revealPlan(blocks: BlockView[], line: number, definition: number
|
||||
return target && { id: target.block.id, expand: target.ancestors }
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标块被正在编辑的祖先盖住、不在页面上(库规则的卡片正在编辑,目标是它下面留下的方法):改成闪盖住它的那块,
|
||||
* 展开那块的祖先,不再找目标里的属性行。covered 见 coveredByEditing。
|
||||
*/
|
||||
export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap<string, string>): RevealPlan {
|
||||
const owner = covered.get(plan.id)
|
||||
return owner === undefined ? plan : { id: owner, expand: blockPath(owner).slice(0, -1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 让一块露出来时视口顶部要在的位置,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。
|
||||
* viewTop / viewHeight 是视口里露出块的那部分(钉住的筛选栏下面)。
|
||||
|
||||
@@ -80,7 +80,7 @@ interface BlockViewBase {
|
||||
id: string
|
||||
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */
|
||||
kind: string
|
||||
/** 编辑范围的起止行,0 起:一般是 header,库规则认领的块到最后一个并进来的子块为止 */
|
||||
/** 编辑范围的起止行,0 起:一般是 header,库规则认领的块是原块整条语句(盖住它留下的子块,见 coveredByEditing) */
|
||||
startLine: number
|
||||
endLine: number
|
||||
/** 编辑范围的源码,去掉共同缩进 */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DocumentMessage, Reveal } from './types'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { blockPath } from './filter'
|
||||
import { revealPlan, scrollTopToShow } from './reveal'
|
||||
import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal'
|
||||
import { blockForLine } from './scrollPosition'
|
||||
import { measureBlocks, pinnedHeight } from './useScrollPosition'
|
||||
|
||||
@@ -28,12 +28,14 @@ function flash(el: HTMLElement): void {
|
||||
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
|
||||
* ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)——
|
||||
* 每一步之后重新渲染、再进来一次;最后把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。
|
||||
* 目标被正在编辑的祖先盖住时闪那个祖先(uncoveredPlan)。
|
||||
*/
|
||||
export function useReveal(
|
||||
doc: DocumentMessage | null,
|
||||
reveal: Reveal | null,
|
||||
collapsed: ReadonlySet<string>,
|
||||
filtered: ReadonlySet<string>,
|
||||
covered: ReadonlyMap<string, string>,
|
||||
show: (ids: string[]) => void,
|
||||
expand: (ids: string[]) => void,
|
||||
done: () => void,
|
||||
@@ -48,7 +50,8 @@ export function useReveal(
|
||||
done()
|
||||
return
|
||||
}
|
||||
const plan = revealPlan(doc.blocks, reveal.line, reveal.definition)
|
||||
const found = revealPlan(doc.blocks, reveal.line, reveal.definition)
|
||||
const plan = found && uncoveredPlan(found, covered)
|
||||
const path = plan ? blockPath(plan.id) : []
|
||||
if (path.some(id => filtered.has(id))) {
|
||||
show(path)
|
||||
@@ -71,5 +74,5 @@ export function useReveal(
|
||||
flash(el)
|
||||
}
|
||||
done()
|
||||
}, [doc, reveal, collapsed, filtered, show, expand, done])
|
||||
}, [doc, reveal, collapsed, filtered, covered, show, expand, done])
|
||||
}
|
||||
Reference in new issue
Block a user