From ad5f559e92ad5ecc34d86981b44c83f7e6b8a6ac Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 13 Oct 2026 16:32:18 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E9=A2=84=E8=A7=88=E5=8F=91=E7=BB=99=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E5=9D=97=E5=8F=AA=E8=AE=B0=E7=9B=B8=E5=AF=B9?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=EF=BC=9A=E5=9D=97=E5=9C=A8=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E9=87=8C=E5=8D=A0=E7=9A=84=E5=9C=B0=E6=96=B9=E8=AE=B0=E6=88=90?= =?UTF-8?q?=E7=A6=BB=E5=89=8D=E4=B8=80=E5=9D=97=E5=A4=9A=E8=BF=9C=E3=80=81?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E5=A4=9A=E9=95=BF=EF=BC=8C=E5=9D=97=E9=87=8C?= =?UTF-8?q?=E5=90=8D=E5=AD=97=E7=9A=84=E4=B8=8B=E6=A0=87=E4=BB=8E=E5=9D=97?= =?UTF-8?q?=E7=9A=84=E5=BC=80=E5=A4=B4=E7=AE=97=EF=BC=8C=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=BB=8E=E6=96=87=E4=BB=B6=E5=BC=80=E5=A4=B4=E7=B4=AF=E5=8A=A0?= =?UTF-8?q?=E5=9B=9E=E6=96=87=E4=BB=B6=E9=87=8C=E7=9A=84=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E3=80=81=E5=92=8C=E6=89=A9=E5=B1=95=E7=AB=AF=E6=89=93=E4=BA=A4?= =?UTF-8?q?=E9=81=93=E7=9A=84=E5=9C=B0=E6=96=B9=E9=83=BD=E5=9C=A8=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E6=8D=A2=E7=AE=97=EF=BC=9B=E9=9D=A0=E5=89=8D=E6=94=B9?= =?UTF-8?q?=E4=BA=86=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=90=8E=E9=9D=A2=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=B2=A1=E5=8F=98=E7=9A=84=E5=9D=97=E5=8F=91=E8=BF=87?= =?UTF-8?q?=E5=8E=BB=E4=B8=80=E6=A8=A1=E4=B8=80=E6=A0=B7=EF=BC=8C=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E7=AB=AF=E8=87=AA=E5=B7=B1=E7=9A=84=E5=9D=97=E5=92=8C?= =?UTF-8?q?=E5=8F=91=E7=BB=99=E9=A1=B5=E9=9D=A2=E7=9A=84=E5=9D=97=E5=88=86?= =?UTF-8?q?=E6=88=90=E4=B8=A4=E7=A7=8D=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/diff/previewDiff.ts | 29 ++- .../src/codeReader/editor/definitionPeek.ts | 7 + .../src/codeReader/editor/documentMessage.ts | 19 +- vscode/src/codeReader/editor/messages.ts | 47 ++++- vscode/src/codeReader/editor/pageBlocks.ts | 167 +++++++++++++++ .../codeReader/editor/previewEditor.test.ts | 7 +- vscode/src/codeReader/editor/previewEditor.ts | 14 +- vscode/webview-ui/src/codeReader/App.test.tsx | 4 +- vscode/webview-ui/src/codeReader/App.tsx | 20 +- .../src/codeReader/BlockTree.test.tsx | 30 +-- .../webview-ui/src/codeReader/BlockTree.tsx | 137 +++++++------ .../src/codeReader/CallTreeDialog.test.tsx | 7 +- .../webview-ui/src/codeReader/CodeEditor.tsx | 19 +- .../codeReader/DefinitionPeekPopup.test.tsx | 4 +- .../src/codeReader/DefinitionPeekPopup.tsx | 7 +- .../webview-ui/src/codeReader/SegmentText.tsx | 37 +++- .../src/codeReader/StickyLines.test.tsx | 7 +- .../webview-ui/src/codeReader/StickyLines.tsx | 27 +-- .../src/codeReader/TextPreview.test.tsx | 3 +- .../src/codeReader/diffView.test.ts | 39 ++-- vscode/webview-ui/src/codeReader/diffView.ts | 21 +- .../src/codeReader/displayNames.test.tsx | 28 ++- .../webview-ui/src/codeReader/displayNames.ts | 6 +- .../webview-ui/src/codeReader/editing.test.ts | 30 +-- vscode/webview-ui/src/codeReader/editing.ts | 25 +-- vscode/webview-ui/src/codeReader/filter.ts | 2 +- .../src/codeReader/itemHeight.test.ts | 4 +- .../src/codeReader/occurrences.test.ts | 52 +++-- .../webview-ui/src/codeReader/occurrences.ts | 33 +-- .../src/codeReader/pageLayout.test.ts | 22 +- .../webview-ui/src/codeReader/pageLayout.ts | 37 +++- vscode/webview-ui/src/codeReader/pageState.ts | 15 +- .../src/codeReader/placement.test.ts | 84 ++++++++ vscode/webview-ui/src/codeReader/placement.ts | 54 +++++ .../src/codeReader/positions.test.tsx | 8 +- .../webview-ui/src/codeReader/reveal.test.ts | 193 ++++++++++-------- vscode/webview-ui/src/codeReader/reveal.ts | 25 ++- .../webview-ui/src/codeReader/testBlocks.ts | 20 ++ vscode/webview-ui/src/codeReader/types.ts | 50 +++-- .../src/codeReader/useDefinitionLinks.ts | 24 ++- .../src/codeReader/useDefinitionPeek.ts | 17 +- .../src/codeReader/useOccurrences.ts | 14 +- vscode/webview-ui/src/codeReader/useReveal.ts | 9 +- 43 files changed, 980 insertions(+), 424 deletions(-) create mode 100644 vscode/src/codeReader/editor/pageBlocks.ts create mode 100644 vscode/webview-ui/src/codeReader/placement.test.ts create mode 100644 vscode/webview-ui/src/codeReader/placement.ts create mode 100644 vscode/webview-ui/src/codeReader/testBlocks.ts diff --git a/vscode/src/codeReader/diff/previewDiff.ts b/vscode/src/codeReader/diff/previewDiff.ts index 2b4ebc5..2eb0c70 100644 --- a/vscode/src/codeReader/diff/previewDiff.ts +++ b/vscode/src/codeReader/diff/previewDiff.ts @@ -1,12 +1,15 @@ import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' +import type { PageCard } from '../editor/messages' import type { ChangedBlock, DiffStats, RemovedBlock } from './blockDiff' import type { DiffBase } from './diffBase' +import { pageCard, pageMarks, sourceLines } from '../editor/pageBlocks' import { splitLines } from '../editText' import { diffBlockTrees } from './blockDiff' -/** 删掉的一块(见 RemovedBlock),带上种类的名字:旧版本里才有的种类,新文件的筛选属性里查不到它叫什么。 */ -export interface RemovedView extends RemovedBlock { +/** 删掉的一块(见 RemovedBlock)发给页面的样子:旧版本里它自己的内容(见 pageCard),带上种类的名字(旧版本里才有的种类,新文件的筛选属性里查不到它叫什么)。 */ +export interface RemovedView extends Omit { + block: PageCard kindName: string } @@ -49,6 +52,20 @@ function kindNames(attributes: readonly FileAttribute[]): Map { return new Map(attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])) } +/** 各块的开头(第一行的行首)在文件里的下标,按块的记号:卡片里要标的改动发给页面时从块的开头算(见 pageMarks) */ +function blockStarts({ blocks, source }: SentDocument): Map { + const lines = sourceLines(source) + const starts = new Map() + const visit = (list: readonly BlockView[]): void => { + for (const block of list) { + starts.set(block.anchor, lines.start(block.startLine)) + visit(block.children) + } + } + visit(blocks) + return starts +} + /** 按基准比出来的差异:基准里没有这个文件时整份是新文件,否则两棵块树比(见 diffBlockTrees)。 */ export function documentDiff(base: DiffBase, sent: SentDocument, old: { source: string, document: OldDocument } | null): DocumentDiff { const common = { base: base.label, version: sent.version, lineCount: splitLines(sent.source).length } @@ -56,7 +73,13 @@ export function documentDiff(base: DiffBase, sent: SentDocument, old: { source: return { ...common, newFile: base.missing, blocks: {}, removed: [], stats: NO_CHANGES } const { blocks, removed, stats } = diffBlockTrees({ blocks: old.document.blocks, source: old.source }, sent) const kinds = kindNames(old.document.attributes) - return { ...common, blocks, removed: removed.map(item => ({ ...item, kindName: kinds.get(item.block.kind) ?? item.block.kind })), stats } + const starts = blockStarts(sent) + return { + ...common, + blocks: Object.fromEntries(Object.entries(blocks).map(([anchor, changed]) => [anchor, changed.marks ? { ...changed, marks: pageMarks(changed.marks, starts.get(anchor) ?? 0) } : changed])), + removed: removed.map(item => ({ ...item, block: pageCard(item.block, 0), kindName: kinds.get(item.block.kind) ?? item.block.kind })), + stats, + } } /** diff --git a/vscode/src/codeReader/editor/definitionPeek.ts b/vscode/src/codeReader/editor/definitionPeek.ts index fa029ed..f5e0f99 100644 --- a/vscode/src/codeReader/editor/definitionPeek.ts +++ b/vscode/src/codeReader/editor/definitionPeek.ts @@ -1,7 +1,9 @@ import type { BlockView } from '../blockView' import type { DisplayName } from '../contract' import type { ShownName } from '../nameResolution' +import type { PageBlock } from './messages' import { renderedNames } from '../nameResolution' +import { pageBlocks } from './pageBlocks' function covers(block: BlockView, offset: number): boolean { return block.defines !== undefined && block.defines.startIndex <= offset && offset < block.defines.endIndex @@ -38,6 +40,11 @@ export function peekBlock(blocks: readonly BlockView[], offset: number, line: nu return card } +/** 浮窗里那块发给页面的样子(见 pageBlocks):当那个文件的顶层第一块,span 从文件开头算,页面按它把卡片里的下标换回那个文件里的。 */ +export function pagePeekBlock(block: BlockView, source: string): PageBlock { + return pageBlocks([block], source)[0] +} + /** 卡片上画出来的名字(照抄自源码,下标和长度都对得上)显示成什么中文名;别的不带过去。 */ export function namesOnCard(block: BlockView, names: readonly ShownName[]): ShownName[] { const rendered = renderedNames([block]) diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 759fc95..7f7be20 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -1,4 +1,4 @@ -import type { NamedKind } from '../blockView' +import type { BlockView, NamedKind } from '../blockView' import type { DisplayName, LocalSymbol, NameBinding, RuleContext, SourceDocument } from '../contract' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' @@ -9,6 +9,7 @@ import { readBlockViews } from '../blockView' import { commandRuleLookup } from '../commandRules' import { markEntryBlocks } from '../entries/entryMarks' import { ruleAttributeNames } from '../libraryRules' +import { pageBlocks } from './pageBlocks' /** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */ export interface DocumentSnapshot { @@ -21,13 +22,21 @@ export interface DocumentSnapshot { entryNames?: readonly string[] } +/** 扩展端这边的一份文档:同发给预览的 document 消息,块是扩展端的 BlockView(位置都是文件里的);发出去前换成页面收的样子(pageDocument)。 */ +export type DocumentView = Omit & { blocks: BlockView[] } + +/** 发给预览的 document 消息:块换成页面收的样子(见 pageBlocks)。 */ +export function pageDocument({ blocks, ...rest }: DocumentView): DocumentMessage { + return { ...rest, blocks: pageBlocks(blocks, rest.source) } +} + /** - * 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)、 + * 读出来的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)、 * 各条导入引进来的名字(注释里不带路径的引用按它找导入的定义)、各块自己的名字和种类(调用的定义就在这份文档里时认种类用,见 NamedKind)、 * 各个名字的绑定(找中文名时同一个绑定问一处,见 NameBinding)。 */ export interface DocumentRead { - message: DocumentMessage + message: DocumentView displayNames: DisplayName[] symbols: LocalSymbol[] imports: ImportedName[] @@ -36,7 +45,7 @@ export interface DocumentRead { } /** - * 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下; + * 当前文档 → 扩展端这边的一份文档(DocumentView,发出去前见 pageDocument);语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下; * 规则查名字的定义时问 lookup(RuleContext.lookup),翻译插件问调用的名字定义成哪种块时问 calleeKind(SourceDocument.calleeKind)。 * 筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。 * 入口文件里名字对上的块,概念列表的第一个换成入口(markEntryBlocks)。 @@ -52,7 +61,7 @@ export async function documentMessage( const { languageId, version } = document const source = document.getText() const translator = registry.selectedTranslator(languageId) - const message: DocumentMessage = { + const message: DocumentView = { type: 'document', version, languageId, diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index e212aa7..3aea044 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -4,9 +4,10 @@ import type { ClaudeImage } from '../../cc/spawnClaude' import type { FileAttribute } from '../attributes' -import type { BlockView, SourceColors } from '../blockView' +import type { BlockSlot, CallTreeTarget, CommentTarget, SourceColors } from '../blockView' import type { ContractMiss } from '../callTree/endpoint' import type { CallTree } from '../callTree/model' +import type { BlockAttributes, BlockConcept, KindBadge, Markdown, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from '../contract' import type { OpenedFrom } from '../diff/diffBase' import type { DocumentDiff } from '../diff/previewDiff' import type { ShownName } from '../nameResolution' @@ -48,14 +49,52 @@ export interface Occurrence { kind: 'text' | 'read' | 'write' } +/** + * 块在源码里占的地方,按相对的量记,前面的块变长变短、挪了位置它都不跟着变: + * 离前一个兄弟块的结尾(第一个子块从父块的开头算,顶层第一块从文件开头算)隔几行(lineGap)、几个字符(gap), + * 自己占几行(lines,首尾两行的行号差)、几个字符(length,第一行行首到最后一行行尾)。块的开头是它第一行的行首。 + */ +export interface BlockSpan { + lineGap: number + gap: number + lines: number + length: number +} + +/** + * 页面上一张卡片画的内容(发给页面的样子,见 pageCard):同 BlockView 的这几样,块里的下标(名字的 offset、refers、defines) + * 都从块的开头算;不带行号、头部的最后一行(扩展端自己用),「重写注释」只带是换掉还是新写。 + */ +interface PageCardBase { + id: string + anchor: string + kind: string + defines?: SourceRange + attributes: BlockAttributes + concepts: BlockConcept[] + commentTarget?: CommentTarget['kind'] + badge?: KindBadge + slot?: BlockSlot + callTree?: CallTreeTarget +} + +export type PageCard = PageCardBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) + +/** 发给页面的一块(见 pageBlocks):卡片的内容,在源码里占的地方(相对的,见 BlockSpan),子块。内容没变、只是挪了位置的块,发出去一模一样。 */ +export type PageBlock = PageCard & { + span: BlockSpan + children: PageBlock[] +} + /** * 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径,同挑选框)、定义处那一行(0 起), - * 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的)。 + * 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片;它自己当那个文件的顶层第一块,span 从文件开头算), + * 显示成中文名的名字(下标是那个文件里的)。 */ export interface DefinitionPeek { path: string line: number - block: BlockView + block: PageBlock names: ShownName[] } @@ -64,7 +103,7 @@ export interface DocumentMessage { /** TextDocument.version:webview 靠它判断是不是新一份文档 */ version: number languageId: string - blocks: BlockView[] + blocks: PageBlock[] /** 整份源码和它的着色,只这一份:编辑框里一块的源码、着色按它的编辑范围取;解析失败、语言不支持时是空的(没有块) */ source: string colors: SourceColors diff --git a/vscode/src/codeReader/editor/pageBlocks.ts b/vscode/src/codeReader/editor/pageBlocks.ts new file mode 100644 index 0000000..d417389 --- /dev/null +++ b/vscode/src/codeReader/editor/pageBlocks.ts @@ -0,0 +1,167 @@ +import type { BlockView } from '../blockView' +import type { Inline, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment, SourceRange } from '../contract' +import type { BlockMarks, ItemDiff, ItemsDiff, RowDiff, RowsDiff, TextDiff } from '../diff/viewDiff' +import type { BlockSpan, PageBlock, PageCard } from './messages' + +/** + * 扩展端的块(位置都是文件里的)→ 发给页面的块(见 PageBlock):块在源码里占的地方按相对的量记(BlockSpan), + * 块里的下标从块的开头(第一行的行首)算。内容没变、只是前面的代码变长变短挪了位置的块,发出去一模一样,页面认得出它没变。 + * 页面那边从文件开头把各块的 span 累加回文件里的位置(webview-ui 的 placement.ts),两边的算法互逆。 + */ + +/** 一份源码各行行首、行尾(不含换行符)的下标 */ +export interface SourceLines { + start: (line: number) => number + end: (line: number) => number +} + +export function sourceLines(source: string): SourceLines { + const starts = [0] + for (let at = source.indexOf('\n'); at >= 0; at = source.indexOf('\n', at + 1)) + starts.push(at + 1) + const start = (line: number): number => starts[Math.min(Math.max(line, 0), starts.length - 1)] + const end = (line: number): number => { + const next = line + 1 < starts.length ? starts[line + 1] - 1 : source.length + return next > start(line) && source[next - 1] === '\r' ? next - 1 : next + } + return { start, end } +} + +function shiftRange(range: SourceRange, base: number): SourceRange { + return { startIndex: range.startIndex - base, endIndex: range.endIndex - base } +} + +function shiftSegment(segment: Segment, base: number): Segment { + if (segment.offset === undefined && !segment.refers) + return segment + return { + ...segment, + ...(segment.offset !== undefined && { offset: segment.offset - base }), + ...(segment.refers && { refers: shiftRange(segment.refers, base) }), + } +} + +function shiftInline(pieces: readonly Inline[], base: number): Inline[] { + return pieces.map(piece => 'role' in piece ? shiftSegment(piece, base) : { ...piece, segments: shiftInline(piece.segments, base) }) +} + +function shiftText(text: PreviewText, base: number): PreviewText { + return 'markdown' in text ? text : shiftInline(text, base) +} + +function shiftItem(item: PropertyItem, base: number): PropertyItem { + return { + ...item, + cells: item.cells.map(cell => shiftText(cell, base)), + ...(item.defines && { defines: shiftRange(item.defines, base) }), + ...(item.items && { items: item.items.map(inner => shiftItem(inner, base)) }), + ...(item.table && { table: shiftTable(item.table, base) }), + } +} + +function shiftTable(table: PreviewTable, base: number): PreviewTable { + return { ...table, items: table.items.map(item => shiftItem(item, base)) } +} + +function shiftRow(row: PropertyRow, base: number): PropertyRow { + return { + ...row, + value: shiftText(row.value, base), + ...(row.defines && { defines: shiftRange(row.defines, base) }), + ...(row.items && { items: row.items.map(item => shiftItem(item, base)) }), + } +} + +/** 一块卡片画的内容(见 PageCard):下标从 base(块第一行的行首在文件里的下标)算,不带行号、子块。 */ +export function pageCard(block: BlockView, base: number): PageCard { + const card = { + id: block.id, + anchor: block.anchor, + kind: block.kind, + ...(block.defines && { defines: shiftRange(block.defines, base) }), + attributes: block.attributes, + concepts: block.concepts, + ...(block.commentTarget && { commentTarget: block.commentTarget.kind }), + ...(block.badge && { badge: block.badge }), + ...(block.slot && { slot: block.slot }), + ...(block.callTree && { callTree: block.callTree }), + } + if ('properties' in block) + return { ...card, properties: block.properties.map(row => shiftRow(row, base)) } + return { + ...card, + translation: shiftText(block.translation, base), + ...(block.items && { items: block.items.map(item => shiftItem(item, base)) }), + ...(block.table && { table: shiftTable(block.table, base) }), + ...(block.doc && { doc: block.doc }), + ...(block.rows && { rows: block.rows.map(row => shiftRow(row, base)) }), + } +} + +/** 文件里的一处:行号、下标 */ +interface Point { line: number, offset: number } + +/** + * 一串兄弟块发给页面的样子:每块的开头是它第一行的行首、结尾是最后一行的行尾,离前一块的结尾(第一块从 from 算)隔多远、自己多长记成 span, + * 子块从这块的开头算起。 + */ +function pageList(blocks: readonly BlockView[], lines: SourceLines, from: Point): PageBlock[] { + let cursor = from + return blocks.map((block) => { + const start = { line: block.startLine, offset: lines.start(block.startLine) } + const end = { line: block.endLine, offset: lines.end(block.endLine) } + const span: BlockSpan = { lineGap: start.line - cursor.line, gap: start.offset - cursor.offset, lines: end.line - start.line, length: end.offset - start.offset } + cursor = end + return { ...pageCard(block, start.offset), span, children: pageList(block.children, lines, start) } + }) +} + +/** 一份源码的块树发给页面的样子(见 PageBlock):顶层第一块从文件开头算。 */ +export function pageBlocks(blocks: readonly BlockView[], source: string): PageBlock[] { + return pageList(blocks, sourceLines(source), { line: 0, offset: 0 }) +} + +function shiftTextDiff(diff: TextDiff, base: number): TextDiff { + return 'markdown' in diff ? diff : { inline: diff.inline.map(part => ({ ...part, segments: shiftInline(part.segments, base) })) } +} + +function shiftCells(cells: Record, base: number): Record { + return Object.fromEntries(Object.entries(cells).map(([column, cell]) => [column, shiftTextDiff(cell, base)])) +} + +function shiftItemDiff(diff: ItemDiff, base: number): ItemDiff { + return { + ...diff, + ...(diff.cells && { cells: shiftCells(diff.cells, base) }), + ...(diff.items && { items: shiftItemsDiff(diff.items, base) }), + ...(diff.table && { table: shiftItemsDiff(diff.table, base) }), + } +} + +function shiftItemsDiff(diff: ItemsDiff, base: number): ItemsDiff { + return { ...diff, changed: Object.fromEntries(Object.entries(diff.changed).map(([index, item]) => [index, shiftItemDiff(item, base)])) } +} + +function shiftRowDiff(diff: RowDiff, base: number): RowDiff { + return { + ...diff, + ...(diff.value && { value: shiftTextDiff(diff.value, base) }), + ...(diff.note && { note: shiftTextDiff(diff.note, base) }), + ...(diff.items && { items: shiftItemsDiff(diff.items, base) }), + } +} + +function shiftRowsDiff(diff: RowsDiff, base: number): RowsDiff { + return { ...diff, changed: Object.fromEntries(Object.entries(diff.changed).map(([index, row]) => [index, shiftRowDiff(row, base)])) } +} + +/** 卡片里要标的改动(BlockMarks)发给页面的样子:新加、没变的词带的下标同卡片的内容,从块的开头(base)算;删掉的词本来就不带下标。 */ +export function pageMarks(marks: BlockMarks, base: number): BlockMarks { + return { + ...(marks.rows && { rows: shiftRowsDiff(marks.rows, base) }), + ...(marks.translation && { translation: shiftTextDiff(marks.translation, base) }), + ...(marks.items && { items: shiftItemsDiff(marks.items, base) }), + ...(marks.table && { table: shiftItemsDiff(marks.table, base) }), + ...(marks.doc && { doc: shiftTextDiff(marks.doc, base) }), + } +} diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 70a62ab..226d0c8 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -38,9 +38,10 @@ vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/w vi.mock('./pathPreview', () => ({ PATH_PREVIEW_SCHEME: 'code-reader-path', topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }) })) vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() })) /** 排视图数据不真的解析:一份没有块的文档 */ -vi.mock('./documentMessage', () => ({ +vi.mock('./documentMessage', async importOriginal => ({ + ...await importOriginal(), documentMessage: vi.fn(async (input: { languageId: string, version: number }) => ({ - message: { type: 'document', version: input.version, languageId: input.languageId, blocks: [], attributes: [], translators: [], translatorId: '', names: [] }, + message: { type: 'document', version: input.version, languageId: input.languageId, blocks: [], source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }, displayNames: [], symbols: [], imports: [], @@ -539,7 +540,7 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () = it('同一个名字:方法说明里的找得到同类的另一个方法,顶层函数说明里的找不到;标坏链接、点了打开都按各自那块找', async () => { const blocks = [declared('class AgentService', 100, [declared(METHOD, 400)]), declared(HELPER, 950)] vi.mocked(documentMessage).mockImplementationOnce(async input => ({ - message: { type: 'document', version: input.version, languageId: input.languageId, blocks, attributes: [], translators: [], translatorId: '', names: [] }, + message: { type: 'document', version: input.version, languageId: input.languageId, blocks, source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }, displayNames: [], symbols: [], imports: [], diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index ee1c365..dc44f08 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -38,10 +38,10 @@ import { askPrompt, followUpPrompt } from '../qa/prompt' import { DocumentLookups } from '../ruleLookups' import { referenceScope, resolveReference } from '../symbolLinks' import { definitionAction } from './definitionAction' -import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek' +import { definitionSiteNames, namesOnCard, pagePeekBlock, peekBlock } from './definitionPeek' import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' import { DefinitionSources } from './definitionSources' -import { documentMessage } from './documentMessage' +import { documentMessage, pageDocument } from './documentMessage' import { documentSymbolNodes } from './documentSymbols' import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { occurrencesOf } from './occurrences' @@ -431,7 +431,7 @@ class Preview { const reveal = this.pendingReveal this.pendingReveal = undefined this.shown = version - this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) }) + this.post({ ...pageDocument(message), names: this.names.known(version), ...(reveal && { reveal }) }) if (this.sent) void this.diff.update(this.sent) // 文档在解析期间又改了的,等下一次发文档再查 @@ -847,8 +847,10 @@ class Preview { const path = workspace.asRelativePath(uri) if (uri.toString() === this.document.uri.toString()) { const { sent } = this - const block = sent?.version === target.version ? peekBlock(sent.blocks, offset, line) : undefined - return block && { path, line, block, names: namesOnCard(block, this.names.known(target.version)) } + if (sent?.version !== target.version) + return undefined + const block = peekBlock(sent.blocks, offset, line) + return block && { path, line, block: pagePeekBlock(block, sent.source), names: namesOnCard(block, this.names.known(target.version)) } } const { message, displayNames } = await documentMessage( { languageId: target.languageId, version: target.version, getText: () => target.getText(), fileName: target.fileName, entryNames: this.owner.entries.namesOf(uri) }, @@ -858,7 +860,7 @@ class Preview { () => {}, ) const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined - return block && { path, line, block, names: namesOnCard(block, definitionSiteNames(displayNames)) } + return block && { path, line, block: pagePeekBlock(block, message.source), names: namesOnCard(block, definitionSiteNames(displayNames)) } } catch (err) { logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` }) diff --git a/vscode/webview-ui/src/codeReader/App.test.tsx b/vscode/webview-ui/src/codeReader/App.test.tsx index ec41e66..af2ec16 100644 --- a/vscode/webview-ui/src/codeReader/App.test.tsx +++ b/vscode/webview-ui/src/codeReader/App.test.tsx @@ -7,6 +7,7 @@ import { act } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { App } from './App' +import { NO_SPAN } from './testBlocks' import { createViewStateStore } from './viewState' /** @@ -118,8 +119,7 @@ function block(id: string, anchor: string): BlockView { anchor, kind: 'function', callTree: {}, - startLine: 0, - endLine: 1, + span: NO_SPAN, attributes: { kind: 'function' }, concepts: [{ id: 'function', label: 'function', icon: 'braces' }], children: [], diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 6bdee0a..e337f29 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -19,6 +19,7 @@ import { FilterBar } from './FilterBar' import { OverviewRuler } from './OverviewRuler' import { pageLayout } from './pageLayout' import { createPageStore } from './pageState' +import { NO_PLACEMENT, placementOf } from './placement' import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa' import { TextMarksContext } from './SegmentText' import { shareUnchanged } from './sharing' @@ -71,7 +72,7 @@ export function App(): ReactElement | null { const rewrite = useRewrite() const [page] = useState(() => createPageStore(viewState.get())) /** 页面上文字的标记:这个文档版本的中文名(文档带来的,扩展端查到了再换)和单击高亮的名字;各段文字按自己那几段订阅 */ - const [marks] = useState(() => createStore({ names: new Map() })) + const [marks] = useState(() => createStore({ names: new Map(), placement: NO_PLACEMENT })) /** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */ const restoreCall = useRef(viewState.get().callTree) /** 块树所在的页面内容:钉住卡片时在这里面量 */ @@ -79,11 +80,13 @@ export function App(): ReactElement | null { /** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来 */ const attachContent = useCallback((el: HTMLDivElement | null) => { content.current = el - return el ? pageLayout.attach(el, document.querySelector('.cr-filter-bar')) : undefined + return el ? pageLayout.attach(el, document.querySelector('.cr-filter-bar'), () => page.get().placement) : undefined }, []) // 这一层重画了:同一段脚本里接着要量位置的(滚到某一行、跳到改动、滚到当前那一处)不能用重画前量的,先作废;放在它们前面 useLayoutEffect(() => pageLayout.invalidate()) - const { doc, editing, folds, reveal, excluded, diff, onlyChanges, openCall, changes, collapsed, filter, covered, unshown, stops } = useStore(page, current => current) + const { doc, editing, folds, reveal, excluded, diff, onlyChanges, openCall, changes, collapsed, filter, covered, placement, unshown, stops } = useStore(page, current => current) + /** 眼下这一版文档里各块在文件里的位置:事件里读名字的下标时现取 */ + const currentPlacement = useCallback(() => page.get().placement, [page]) const pathChat = useStore(qa.store, state => chatOf(state, PATH_ASK_ANCHOR), sameChat) const qaAvailable = useStore(qa.store, state => state.available) const qaSubject = useStore(qa.store, state => state.subject) @@ -97,8 +100,8 @@ export function App(): ReactElement | null { const revealed = useCallback(() => page.update({ reveal: null }), [page]) useScrollPosition(doc) useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed) - useDefinitionLinks(doc?.version) - const peek = useDefinitionPeek(doc?.version) + useDefinitionLinks(doc?.version, currentPlacement) + const peek = useDefinitionPeek(doc?.version, currentPlacement) const sticky = useStickySettings() useRepoLinks() useRepoImages() @@ -106,11 +109,12 @@ export function App(): ReactElement | null { const occurrencePage = useMemo((): OccurrencePage | undefined => doc ? { blocks: doc.blocks, + placement, skip: id => unshown.has(id) || covered.has(id) || editing.includes(id), collapsed, expand, } - : undefined, [doc, unshown, covered, editing, collapsed, expand]) + : undefined, [doc, placement, unshown, covered, editing, collapsed, expand]) const occurrences = useOccurrences(doc?.version, doc?.translatorId, occurrencePage, marks) /** 块树:状态是页面 store 里的 tree,动作改页面 store;整页一份,卡片拿到的回调一直是同一个 */ @@ -124,7 +128,7 @@ export function App(): ReactElement | null { const leaving = current.editing.includes(id) return { focusId: leaving ? null : id, - editing: leaving ? current.editing.filter(other => other !== id) : startEditing(current.doc?.blocks ?? [], current.editing, id), + editing: leaving ? current.editing.filter(other => other !== id) : startEditing(current.placement, current.editing, id), } }), onFocused: () => page.update({ focusId: null }), @@ -150,7 +154,7 @@ export function App(): ReactElement | null { // 和上一份内容一样的块还是原来的对象:没变的卡片不重画 const next = shareUnchanged(page.get().doc, msg) const reopened = restoring && openingCall(next, restoring.anchor, restoring.entry) - marks.set(current => ({ ...current, names: nameMap(msg.names) })) + marks.set(current => ({ ...current, names: nameMap(msg.names), placement: placementOf(next.blocks) })) page.update(current => ({ doc: next, keep: keptWhileEditing(current.keep, current.editing), diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index 3733541..d43d8b4 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -7,6 +7,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { fileAttributes } from '../../../src/codeReader/attributes' import { readBlockViews, toBlockViews } from '../../../src/codeReader/blockView' +import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks' import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { fastapiRule } from '../../../src/codeReader/rules/fastapi' import { runtime, WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' @@ -14,8 +15,10 @@ import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh import { BlockTree, treeState } from './BlockTree' import { applyFilter } from './filter' import { FilterBar } from './FilterBar' +import { NO_PLACEMENT, placementOf } from './placement' import { EMPTY_CHAT } from './qa' import { staticStore } from './store' +import { NO_SPAN } from './testBlocks' import { collapsedIds } from './viewState' // 编辑框连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着 @@ -29,8 +32,7 @@ function view(id: string, kind: string, text: string, extra: Partial id, anchor: id, kind, - startLine: 0, - endLine: 0, + span: NO_SPAN, attributes: { kind }, concepts: [{ id: kind, label: badge?.label ?? kind, icon: badge?.icon ?? 'box', ...(badge && { color: badge.color }) }], children: [], @@ -66,7 +68,7 @@ function rewriteTree(state: ChatState): RewriteTree { function tree(view: Partial> = {}, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeHandle { return { state: staticStore(treeState({ - edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, editing: new Set(), focusId: null }, + edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, placement: NO_PLACEMENT, editing: new Set(), focusId: null }, collapsed: new Set(), hidden: new Set(), temporary: new Set(), @@ -103,16 +105,17 @@ describe('路由筛选后展开函数体', () => { ' return "unrelated-body"', ].join('\n') const language = createPythonAdapter(WASM_DIR) - const blocks = await withBlocks(language, 'python', source, parsed => toBlockViews( + const views = await withBlocks(language, 'python', source, parsed => toBlockViews( parsed, { source, language, markers: [] }, pythonZhTranslator, { rules: [fastapiRule], report: (message) => { throw new Error(message) } }, )) + const blocks = pageBlocks(views, source) const route = blocks.find(block => block.kind === 'fastapi.route')! expect(route.children.map(block => block.kind)).toEqual(['if', 'return']) expect(route.children[0].children.map(block => block.kind)).toEqual(['return']) - const attributes = fileAttributes(blocks, pythonZhTranslator.attributeNames) + const attributes = fileAttributes(views, pythonZhTranslator.attributeNames) const excluded = { kind: attributes.find(attribute => attribute.id === 'kind')!.values.map(value => value.id).filter(kind => kind !== 'fastapi.route') } const filtered = applyFilter(blocks, excluded, new Set()) const view = { collapsed: collapsedIds(blocks, {}), hidden: filtered.hidden, temporary: filtered.temporary } @@ -195,7 +198,7 @@ describe('「注释」一行和「重写注释」按钮', () => { it('属性表卡片:「注释」一行照属性表画,按钮在右上角那排,不在这一行上', () => { const card = view('1', 'function', '', { properties: [{ label: '名称', value: [{ text: 'find', role: 'function' }] }, comment, { label: '异步', value: [{ text: '否', role: 'storage' }] }], - commentTarget: { kind: 'replace', startLine: 0, endLine: 0 }, + commentTarget: 'replace', }) const markup = html(card) inToolbar(markup, '按注释规范重写') @@ -203,13 +206,13 @@ describe('「注释」一行和「重写注释」按钮', () => { }) it('一行译文的卡片:「注释」一行排在译文下面,按钮同样在右上角那排', () => { - const markup = html(view('2', 'statement', 'save()', { rows: [comment], commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })) + const markup = html(view('2', 'statement', 'save()', { rows: [comment], commentTarget: 'replace' })) expect(markup.indexOf('save()')).toBeLessThan(markup.indexOf('按名字找')) inToolbar(markup, '按注释规范重写') }) it('重写过的块:下面一行「重写过 N 次」带灰字历史;记录收着时按钮先展开它,展开着时再点才重写', () => { - const block = view('5', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } }) + const block = view('5', 'statement', 'save()', { commentTarget: 'replace' }) const entry = { id: 'r1', question: '/ecc:writing-code-comments a.py:1-1', answer: '保存。', createdAt: '2026-09-25T10:00:00.000Z' } const closed = { ...EMPTY_CHAT, available: true, threads: [{ anchor: block.anchor, entries: [entry] }] } const collapsedMarkup = markup([block], tree({}, closed)) @@ -222,7 +225,7 @@ describe('「注释」一行和「重写注释」按钮', () => { }) it('展开着又在写或排着队时按钮不能点;出错、停下的不挡', () => { - const block = view('6', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } }) + const block = view('6', 'statement', 'save()', { commentTarget: 'replace' }) const button = (status: Ask['status']): string => { const rewrite = { ...EMPTY_CHAT, available: true, open: [block.anchor], asks: [{ askId: 'r1', anchor: block.anchor, question: 'q', text: '', status }] } return /]*aria-label="按注释规范重写"[^>]*>/.exec(markup([block], tree({}, rewrite)))?.[0] ?? '' @@ -233,7 +236,7 @@ describe('「注释」一行和「重写注释」按钮', () => { }) it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => { - inToolbar(html(view('3', 'statement', 'save()', { commentTarget: { kind: 'insert', line: 0 } })), '按注释规范写注释') + inToolbar(html(view('3', 'statement', 'save()', { commentTarget: 'insert' })), '按注释规范写注释') expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范') }) }) @@ -243,7 +246,7 @@ describe('一行译文下面拆开的各项', () => { async function returned(source: string): Promise { const language = createPythonAdapter(WASM_DIR) const views = await withBlocks(language, 'python', source, blocks => toBlockViews(blocks, { source, language, markers: [], calleeKind: call => call.callee.text === 'Filter' ? 'class' : undefined }, pythonZhTranslator)) - return views[0].children[0] + return pageBlocks(views, source)[0].children[0] } const SOURCE = [ @@ -323,9 +326,10 @@ describe('一行译文下面拆开的各项', () => { describe('编辑框', () => { it('编辑着的块:编辑框里是文档源码按它的编辑范围取的整行、去掉共同缩进,着色取自整份源码的着色', async () => { const source = 'def f(x):\n if x:\n return "a"\n' - const { blocks, colors } = await readBlockViews(runtime, createPythonAdapter(WASM_DIR), pythonZhTranslator, 'python', source) + const { blocks: views, colors } = await readBlockViews(runtime, createPythonAdapter(WASM_DIR), pythonZhTranslator, 'python', source) + const blocks = pageBlocks(views, source) const html = markup(blocks, tree({ - edit: { version: 1, sourceText: { source, colors }, editing: new Set(['0.0']), focusId: null }, + edit: { version: 1, sourceText: { source, colors }, placement: placementOf(blocks), editing: new Set(['0.0']), focusId: null }, collapsed: new Set(), })) expect(html).toContain('>if x:\n return "a"') diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 3e286f3..5d318fe 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -2,11 +2,12 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react' import type { SourceText } from './blockSource' import type { RewriteTool } from './BlockTools' import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView' +import type { Place, Placement } from './placement' import type { GridSlots } from './PropertyGrid' import type { ChatState, ChatView, QaState } from './qa' import type { PendingImage } from './qaImages' import type { ReadableStore } from './store' -import type { BlockMarks, BlockView, ChangedBlock } from './types' +import type { BlockMarks, BlockView, CardView, ChangedBlock } from './types' import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react' import { createContext, Fragment, memo, useContext, useMemo, useState } from 'react' import { AskThread, REWRITE_THREAD } from './AskThread' @@ -18,6 +19,7 @@ import { estimatedHeight } from './itemHeight' import { KindIcon } from './kindIcons' import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid' import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa' +import { BlockIdContext } from './SegmentText' import { shallowEqual, staticStore, useStore } from './store' import { TextPreview } from './TextPreview' import { foldsByCard, listId, tableId } from './viewState' @@ -27,6 +29,8 @@ export interface EditState { version: number /** 这一版文档的源码和着色:编辑框按块的编辑范围从里面取 */ sourceText: SourceText + /** 这一版文档里各块在文件里的位置:编辑范围、发回改动时的行号按它算 */ + placement: Placement editing: ReadonlySet /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null @@ -139,6 +143,7 @@ export function treeState(state: Omit, previous?: Block } const NO_FOLDS: ReadonlySet = new Set() +const NO_CHILDREN: BlockView[] = [] const NO_REMOVED: readonly PlacedRemoved[] = [] /** 没有问 AI、重写注释的块树(定义卡片浮窗):一直不能用 */ const NO_CHAT: ReadableStore = staticStore(EMPTY_CHAT) @@ -211,8 +216,9 @@ interface BlockShape { focus: boolean /** 编辑框每来一份新文档都要对一次内容;不在编辑的卡片不跟着版本重画 */ version?: number - /** 编辑着的卡片才有:这一版文档的源码和着色 */ + /** 编辑着的卡片才有:这一版文档的源码和着色,这块在文件里的位置 */ sourceText?: SourceText + place?: Place /** 调用树:扩展端标了能看调用树(BlockView.callTree)的块才有棵数 */ calls?: number callOpen: boolean @@ -235,6 +241,7 @@ function shapeOf(block: BlockView, state: BlockTreeState): BlockShape { focus: state.edit?.focusId === block.id, version: editing ? state.edit?.version : undefined, sourceText: editing ? state.edit?.sourceText : undefined, + place: editing ? state.edit?.placement.of(block.id) : undefined, calls: state.callTree && block.callTree ? state.callTree.counts.get(block.anchor) ?? 0 : undefined, callOpen: state.callTree?.openAnchor === block.anchor, } @@ -273,7 +280,7 @@ function rewriteOf(block: BlockView, state: ChatState): RewriteShape { return { ...(block.commentTarget && { tool: { - fresh: block.commentTarget.kind === 'insert', + fresh: block.commentTarget === 'insert', rewriting: chat.open && chat.asks.some(unfinished), reopen: !chat.open && (chat.asks.length > 0 || chat.history.length > 0), }, @@ -310,7 +317,7 @@ const BlockItem = memo(({ block, item }: { block: BlockView, item: boolean }): R <> {shape.run !== undefined && tree.onOpenRun(block.id)} />} {shape.shown && ( - + {/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */} {qa && ask.chat && ( @@ -355,10 +362,10 @@ BlockItem.displayName = 'BlockItem' * 顶层一块的外层(见 BlockTree 的 items);不套时原样。离视口远时浏览器跳过它里面的样式、排版和绘制(.cr-item 的 content-visibility), * 没画过时按估的高度(height,见 estimatedHeight)占位,画过一次就记住真实高度。 */ -function Item({ item, line, height, children }: { item: boolean, line: number, height: number, children: ReactNode }): ReactElement { +function Item({ item, height, children }: { item: boolean, height: number, children: ReactNode }): ReactElement { if (!item) return <>{children} - return
{children}
+ return
{children}
} /** @@ -367,7 +374,7 @@ function Item({ item, line, height, children }: { item: boolean, line: number, h * 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。 * slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。items:这一层每块套一层 .cr-item(只有页面上的顶层)。 */ -export function BlockLevel({ blocks, owner, slotted = false, items = false }: { blocks: BlockView[], owner?: BlockView, slotted?: boolean, items?: boolean }): ReactElement { +export function BlockLevel({ blocks, owner, slotted = false, items = false }: { blocks: BlockView[], owner?: CardView, slotted?: boolean, items?: boolean }): ReactElement { const tree = useBlockTree() // 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层 const listed = useMemo(() => slotted ? blocks : blocks.filter(block => !block.slot), [blocks, slotted]) @@ -406,10 +413,11 @@ export function BlockLevel({ blocks, owner, slotted = false, items = false }: { * 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文; * 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。 * folds 是这张卡片里折叠着的(见 foldsByCard);标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。 + * children 是这块的子块,挂在子行上的画在属性表里(删掉的块不画子块,没有)。 */ -const BlockPreview = memo(({ block, folds, marks }: { block: BlockView, folds: ReadonlySet, marks?: BlockMarks }): ReactElement => { +const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { block: CardView, children?: BlockView[], folds: ReadonlySet, marks?: BlockMarks }): ReactElement => { const tree = useBlockTree() - const slots = useMemo(() => cardSlots(block, folds, tree), [block, folds, tree]) + const slots = useMemo(() => cardSlots(block, children, folds, tree), [block, children, folds, tree]) if ('properties' in block) return const folding = { collapsed: folds, onToggleFold: tree.onToggleFold } @@ -441,8 +449,8 @@ BlockPreview.displayName = 'BlockPreview' * 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、 * 同一行里别的收起,展开着的再点收起。卡片没有挂在子行上的子块时没有。 */ -function cardSlots(block: BlockView, folds: ReadonlySet, tree: BlockTreeHandle): GridSlots | undefined { - const blocks = block.children.filter(child => child.slot) +function cardSlots(block: CardView, children: readonly BlockView[], folds: ReadonlySet, tree: BlockTreeHandle): GridSlots | undefined { + const blocks = children.filter(child => child.slot) if (blocks.length === 0) return undefined return { @@ -473,62 +481,63 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl const { change } = shape const { tool } = rewrite return ( -
- {shape.diff && } - {change?.outermost && 新增} - {shape.hasChildren - ? ( - - ) - : } - -
- {edit && shape.version !== undefined && shape.sourceText + +
+ {shape.diff && } + {change?.outermost && 新增} + {shape.hasChildren ? ( - edit.onToggleEdit(block.id)} - /> + ) - : } + : } + +
+ {edit && shape.version !== undefined && shape.sourceText && shape.place + ? ( + edit.onToggleEdit(block.id)} + /> + ) + : } +
+ {shape.temporary && ( + + + 已被筛选隐藏,临时显示 + + )} + {edit && ( + edit.onToggleEdit(block.id)} + asking={ask.asking} + onToggleAsk={tree.qa && ask.asking !== undefined ? () => tree.qa?.onToggle(anchor) : undefined} + rewrite={tree.rewrite && tool && { ...tool, onRewrite: () => tool.reopen ? tree.rewrite?.onOpen(anchor) : tree.rewrite?.onRewrite(anchor) }} + callTree={onOpenCall && shape.calls !== undefined ? { anchor, count: shape.calls, open: shape.callOpen, onOpen: () => onOpenCall(anchor) } : undefined} + /> + )}
- {shape.temporary && ( - - - 已被筛选隐藏,临时显示 - - )} - {edit && ( - edit.onToggleEdit(block.id)} - asking={ask.asking} - onToggleAsk={tree.qa && ask.asking !== undefined ? () => tree.qa?.onToggle(anchor) : undefined} - rewrite={tree.rewrite && tool && { ...tool, onRewrite: () => tool.reopen ? tree.rewrite?.onOpen(anchor) : tree.rewrite?.onRewrite(anchor) }} - callTree={onOpenCall && shape.calls !== undefined ? { anchor, count: shape.calls, open: shape.callOpen, onOpen: () => onOpenCall(anchor) } : undefined} - /> - )} -
+ ) } diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx index 9a1f3f2..948cd40 100644 --- a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx @@ -9,7 +9,9 @@ import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockTree, treeState } from './BlockTree' import { CallTreeDialog, splitWhere } from './CallTreeDialog' +import { NO_PLACEMENT } from './placement' import { staticStore } from './store' +import { NO_SPAN } from './testBlocks' vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) @@ -39,8 +41,7 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block anchor: id, kind, ...(callTree && { callTree }), - startLine: 0, - endLine: 1, + span: NO_SPAN, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }], children: [], @@ -52,7 +53,7 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block function tree(callTree?: CallTreeState & { onOpen: (anchor: string) => void }, edit = true): BlockTreeHandle { return { state: staticStore(treeState({ - ...(edit && { edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, editing: new Set(), focusId: null } }), + ...(edit && { edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, placement: NO_PLACEMENT, editing: new Set(), focusId: null } }), collapsed: new Set(), hidden: new Set(), temporary: new Set(), diff --git a/vscode/webview-ui/src/codeReader/CodeEditor.tsx b/vscode/webview-ui/src/codeReader/CodeEditor.tsx index 83bd83b..6c094eb 100644 --- a/vscode/webview-ui/src/codeReader/CodeEditor.tsx +++ b/vscode/webview-ui/src/codeReader/CodeEditor.tsx @@ -1,6 +1,6 @@ import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react' import type { SourceText } from './blockSource' -import type { BlockView } from './types' +import type { Place } from './placement' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { blockSource } from './blockSource' import { recolor } from './segments' @@ -10,9 +10,10 @@ import { postMessage } from './vscode' const EDIT_DEBOUNCE_MS = 200 interface CodeEditorProps { - block: BlockView /** 这一版文档的源码和着色:这块的源码按它的编辑范围从里面取 */ sourceText: SourceText + /** 这块在这一版文档里的位置:编辑范围是它的首尾行 */ + place: Place /** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */ version: number autoFocus: boolean @@ -21,12 +22,12 @@ interface CodeEditorProps { } /** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */ -export function CodeEditor({ block, sourceText, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement { - const { code, tokens } = useMemo(() => blockSource(sourceText, block.startLine, block.endLine), [sourceText, block.startLine, block.endLine]) +export function CodeEditor({ sourceText, place, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement { + const { code, tokens } = useMemo(() => blockSource(sourceText, place.line, place.endLine), [sourceText, place.line, place.endLine]) const [text, setText] = useState(code) const textareaRef = useRef(null) - const blockRef = useRef(block) - blockRef.current = block + const placeRef = useRef(place) + placeRef.current = place const textRef = useRef(text) textRef.current = text /** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */ @@ -42,12 +43,12 @@ export function CodeEditor({ block, sourceText, version, autoFocus, onAutoFocuse if (!unsent.current) return unsent.current = false - const { startLine, endLine } = blockRef.current + const { line, endLine } = placeRef.current const value = textRef.current postMessage({ type: 'edit', - startLine, - endLine: sentLineCount.current === null ? endLine : startLine + sentLineCount.current - 1, + startLine: line, + endLine: sentLineCount.current === null ? endLine : line + sentLineCount.current - 1, text: value, }) sentLineCount.current = value.split('\n').length diff --git a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx index 0560c0d..ee4c83de 100644 --- a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx +++ b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx @@ -2,6 +2,7 @@ import type { BlockView, DefinitionPeek } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { PeekTree } from './DefinitionPeekPopup' +import { NO_SPAN } from './testBlocks' vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) @@ -10,8 +11,7 @@ function block(id: string, kind: string, extra: Partial): BlockView { id, anchor: id, kind, - startLine: 0, - endLine: 0, + span: NO_SPAN, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children: [], diff --git a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx index 9858d94..1aa0384 100644 --- a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx +++ b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx @@ -7,6 +7,7 @@ import { createPortal } from 'react-dom' import { BlockTree, treeState } from './BlockTree' import { PEEK_ATTRIBUTE, placePeek } from './definitionPeek' import { nameMap } from './displayNames' +import { placementOf } from './placement' import { TextMarksContext } from './SegmentText' import { createStore, staticStore } from './store' import { MarkdownKindContext } from './TextPreview' @@ -37,11 +38,13 @@ export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement { onOpenRun: () => {}, } }) - const marks = useMemo(() => staticStore({ names: nameMap(peek.names) }), [peek]) + // 卡片里的下标从块的开头算,那块当那个文件的顶层第一块排,换回那个文件里的下标再查中文名 + const blocks = useMemo(() => [peek.block], [peek]) + const marks = useMemo(() => staticStore({ names: nameMap(peek.names), placement: placementOf(blocks) }), [peek, blocks]) return ( - + ) diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 1f8d962..4597fa7 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -2,36 +2,50 @@ import type { LucideIcon } from 'lucide-react' import type { CSSProperties, ReactElement } from 'react' import type { NameMap } from './displayNames' import type { Highlight, OccurrenceStyle } from './occurrences' +import type { Placement } from './placement' import type { ReadableStore } from './store' import type { GroupStyle, Inline, Segment, SegmentGroup, ShownName } from './types' import { createContext, memo, useContext, useState } from 'react' import { sameShownName, shownName } from './displayNames' import { KIND_ICON_COMPONENTS } from './kindIcons' import { isCurrent, segmentOccurrence, sourceOf } from './occurrences' +import { NO_PLACEMENT } from './placement' import { staticStore, useStore } from './store' -/** 页面上文字的两样标记:中文名(下标 → 显示成什么),眼下高亮着的名字(没有高亮时没有)。 */ +/** + * 页面上文字的标记:中文名(文件里的下标 → 显示成什么),眼下高亮着的名字(没有高亮时没有),和各块在文件里的位置 + * (文字里的下标从所在那块的开头算,按它换成文件里的下标再去查)。 + */ export interface TextMarks { names: NameMap highlight?: Highlight + placement: Placement } /** * 文字的标记从哪份 store 读:页面上是 App 的那份(中文名、单击高亮都记在里面);定义卡片浮窗是那个文件的中文名,钉住的行不画高亮。 * 每段文字只订阅自己那几段的标记。 */ -export const TextMarksContext = createContext>(staticStore({ names: new Map() })) +export const TextMarksContext = createContext>(staticStore({ names: new Map(), placement: NO_PLACEMENT })) + +/** 文字在哪块的卡片里(块 id):卡片、钉住的卡片头按它给里面的文字;卡片外面的文字(编辑框的着色)不带下标,没有 */ +export const BlockIdContext = createContext(undefined) /** - * 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换,带自己颜色(color)的段用它(.cr-colored);带源码下标的名字记在 data-offset 上 - * (讲别处名字的段记那个名字的下标,见 sourceOf),跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时 + * 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换,带自己颜色(color)的段用它(.cr-colored);带下标的名字把块里的下标记在 data-offset 上 + * (讲别处名字的段记那个名字的下标,见 sourceOf;换成文件里的见 nameOffset),跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时 * 再标 data-current。起了中文名的名字显示中文名(data-name),悬停看原名,定义处本身在后面淡淡写原名;下标照旧,跳到定义、高亮照常。 * 带悬停提示(tooltip)的段悬停看它。 * 框起来的一组词画成小框,框里的词同样处理。 - * 中文名和高亮只订阅自己这几段的(TextMarksContext):中文名到了、换了高亮的名字,只有字或底色变了的那几串文字重画。 + * 中文名和高亮只订阅自己这几段的(TextMarksContext),按所在那块(BlockIdContext)在文件里的位置查:中文名到了、换了高亮的名字,只有字或底色变了的那几串文字重画; + * 块只是挪了位置时查出来的还是那些,不重画。 */ export const SegmentText = memo(({ segments }: { segments: Inline[] }): ReactElement => { - const marks = useStore(useContext(TextMarksContext), current => segments.map(piece => 'role' in piece ? markOf(piece, current) : undefined), sameMarks) + const block = useContext(BlockIdContext) + const marks = useStore(useContext(TextMarksContext), (current) => { + const base = block === undefined ? undefined : current.placement.of(block)?.offset + return segments.map(piece => 'role' in piece && base !== undefined ? markOf(piece, base, current) : undefined) + }, sameMarks) return ( <> {segments.map((piece, index) => { @@ -44,7 +58,7 @@ export const SegmentText = memo(({ segments }: { segments: Inline[] }): ReactEle key={index} className={piece.color ? `cr-role-${piece.role} cr-colored` : `cr-role-${piece.role}`} style={piece.color && { '--color-dark': piece.color.dark, '--color-light': piece.color.light } as CSSProperties} - data-offset={sourceOf(piece)?.start} + data-offset={sourceOf(piece, 0)?.start} data-occurrence={mark?.occurrence} data-current={mark?.current ? '' : undefined} data-name={shown?.name} @@ -67,10 +81,11 @@ interface SegmentMark { current: boolean } -function markOf(piece: Segment, { names, highlight }: TextMarks): SegmentMark | undefined { - const shown = shownName(piece, names) - const occurrence = highlight && segmentOccurrence(piece, highlight.occurrences) - const current = highlight !== undefined && isCurrent(piece, highlight) +/** 一段的标记;段里的下标从所在那块的开头算,那块的开头在文件里的下标是 base。 */ +function markOf(piece: Segment, base: number, { names, highlight }: TextMarks): SegmentMark | undefined { + const shown = shownName(piece, base, names) + const occurrence = highlight && segmentOccurrence(piece, base, highlight.occurrences) + const current = highlight !== undefined && isCurrent(piece, base, highlight) return shown || occurrence || current ? { shown, occurrence, current } : undefined } diff --git a/vscode/webview-ui/src/codeReader/StickyLines.test.tsx b/vscode/webview-ui/src/codeReader/StickyLines.test.tsx index 914772a..fba2760 100644 --- a/vscode/webview-ui/src/codeReader/StickyLines.test.tsx +++ b/vscode/webview-ui/src/codeReader/StickyLines.test.tsx @@ -2,9 +2,11 @@ import type { BlockView } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { nameMap } from './displayNames' +import { placementOf } from './placement' import { TextMarksContext } from './SegmentText' import { PinnedHead } from './StickyLines' import { staticStore } from './store' +import { NO_SPAN } from './testBlocks' vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) @@ -13,8 +15,7 @@ function block(kind: string, extra: Partial): BlockView { id: '2', anchor: '2', kind, - startLine: 0, - endLine: 0, + span: NO_SPAN, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'square-function' }], children: [], @@ -25,7 +26,7 @@ function block(kind: string, extra: Partial): BlockView { function render(view: BlockView): string { return renderToStaticMarkup( - + , ) diff --git a/vscode/webview-ui/src/codeReader/StickyLines.tsx b/vscode/webview-ui/src/codeReader/StickyLines.tsx index fd0adb7..afb8a36 100644 --- a/vscode/webview-ui/src/codeReader/StickyLines.tsx +++ b/vscode/webview-ui/src/codeReader/StickyLines.tsx @@ -3,7 +3,7 @@ import type { BlockView, StickyScrollSettings } from './types' import { useContext, useLayoutEffect, useMemo } from 'react' import { KindIcon } from './kindIcons' import { pageLayout } from './pageLayout' -import { SegmentText, TextMarksContext } from './SegmentText' +import { BlockIdContext, SegmentText, TextMarksContext } from './SegmentText' import { selectStore } from './store' import { flash } from './useReveal' import { usePinnedHeight } from './useScrollPosition' @@ -24,20 +24,22 @@ function blocksById(blocks: readonly BlockView[]): Map { /** * 钉住的一行里卡片的头:折叠箭头那格留空、概念图标、标题,各自的位置同卡片上的,一行放不下省略。 - * 标题同卡片上的写法(中文名照换):属性表的第一行(名称)的值,或一行译文;Markdown 只取第一行。 + * 标题同卡片上的写法(中文名照换,标题里的下标同卡片上的从这块的开头算):属性表的第一行(名称)的值,或一行译文;Markdown 只取第一行。 */ export function PinnedHead({ block }: { block: BlockView }): ReactElement { const title = 'properties' in block ? block.properties[0]?.value : block.translation return ( -
- - -

- {title && ('markdown' in title - ? {title.markdown.split('\n', 1)[0].trim()} - : )} -

-
+ +
+ + +

+ {title && ('markdown' in title + ? {title.markdown.split('\n', 1)[0].trim()} + : )} +

+
+
) } @@ -55,7 +57,7 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject< const byId = useMemo(() => blocksById(blocks), [blocks]) // 钉着的名字照换中文名,不画页面上的高亮 const marks = useContext(TextMarksContext) - const unhighlighted = useMemo(() => selectStore(marks, ({ names }) => ({ names })), [marks]) + const unhighlighted = useMemo(() => selectStore(marks, ({ names, placement }) => ({ names, placement })), [marks]) const count = lines.scopes.length const height = count * STICKY_LINE_HEIGHT + lines.offset @@ -90,6 +92,7 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject<
jump(scope.id)} diff --git a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx index f694aeb..4e157f3 100644 --- a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx +++ b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DefinitionPeeks, linkPeekSource } from './definitionPeek' import { DiffText } from './DiffText' import { commentLinkOf } from './repoLink' +import { NO_SPAN } from './testBlocks' import { MarkdownKindContext, TextPreview } from './TextPreview' let container: HTMLDivElement @@ -134,7 +135,7 @@ describe.each(['TextPreview', 'DiffText'] as const)('%s 的 Markdown 页面元 const peek: DefinitionPeek = { path: 'client.py', line: 1, - block: { id: '1', anchor: '1', kind: 'class', startLine: 1, endLine: 2, attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }, + block: { id: '1', anchor: '1', kind: 'class', span: NO_SPAN, attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }, names: [], } await act(async () => controller.answer(1, peek)) diff --git a/vscode/webview-ui/src/codeReader/diffView.test.ts b/vscode/webview-ui/src/codeReader/diffView.test.ts index 955a9ac..1f74146 100644 --- a/vscode/webview-ui/src/codeReader/diffView.test.ts +++ b/vscode/webview-ui/src/codeReader/diffView.test.ts @@ -1,9 +1,12 @@ -import type { BlockView, DocumentDiff, RemovedView } from './types' +import type { WrittenBlock } from './testBlocks' +import type { DocumentDiff, RemovedView } from './types' import { describe, expect, it } from 'vitest' import { changeStops, diffTree, foldUnchanged, nextStop, openedByDiff, removedTitle, statsParts } from './diffView' +import { placementOf } from './placement' +import { placed } from './testBlocks' -function block(id: string, anchor: string, children: BlockView[] = [], extra: Partial = {}): BlockView { - return { id, anchor, kind: 'function', startLine: 0, endLine: 0, attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children, translation: [], ...extra } as BlockView +function block(id: string, anchor: string, children: WrittenBlock[] = [], extra: Partial = {}): WrittenBlock { + return { id, anchor, kind: 'function', attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children, translation: [], ...extra } as WrittenBlock } function removed(parent: string, after: string | undefined, kind = 'function'): RemovedView { @@ -14,10 +17,10 @@ function diff(parts: Partial): DocumentDiff { return { base: '上一次提交', version: 2, blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 }, lineCount: 10, ...parts } } -const BLOCKS = [ +const BLOCKS = placed([ block('0', 'function a', [block('0.0', 'function a > return @1')]), block('1', 'function b'), -] +]) describe('diffTree', () => { it('块按记号认成页面上的 id;页面上找不到的记号(文档刚改)不标', () => { @@ -80,13 +83,13 @@ describe('removedTitle', () => { * 3 function d(新增,带子块 3.0) * 4 function e(没变) */ -const TREE_BLOCKS = [ - block('0', 'a', [block('0.0', 'a0', [], { startLine: 1 }), block('0.1', 'a1', [], { startLine: 2, endLine: 2 }), block('0.2', 'a2', [], { startLine: 3 })], { startLine: 0, endLine: 3 }), - block('1', 'b', [], { startLine: 5 }), - block('2', 'c', [], { startLine: 7 }), - block('3', 'd', [block('3.0', 'd0')], { startLine: 9, endLine: 11 }), - block('4', 'e', [], { startLine: 13 }), -] +const TREE_BLOCKS = placed([ + block('0', 'a', [block('0.0', 'a0', [], { lines: [1, 1] }), block('0.1', 'a1', [], { lines: [2, 2] }), block('0.2', 'a2', [], { lines: [3, 3] })], { lines: [0, 3] }), + block('1', 'b', [], { lines: [5, 5] }), + block('2', 'c', [], { lines: [7, 7] }), + block('3', 'd', [block('3.0', 'd0')], { lines: [9, 11] }), + block('4', 'e', [], { lines: [13, 13] }), +]) function tree(removedList: RemovedView[] = []): NonNullable> { return diffTree(TREE_BLOCKS, diff({ @@ -116,13 +119,13 @@ describe('foldUnchanged', () => { }) /** 一张卡片:两个子块挂在第 8 行的子行上,后面一个留下的子块;只有第二个子块改了,它里面挂在第 2 行子行上的子块自己改了 */ -const CARD_BLOCKS = [ +const CARD_BLOCKS = placed([ block('0', 'card', [ block('0.0', 'm1', [], { slot: { row: 8, item: 0 } }), - block('0.1', 'm2', [block('0.1.0', 'm2s', [], { slot: { row: 2, item: 0 } })], { slot: { row: 8, item: 1 }, startLine: 4 }), + block('0.1', 'm2', [block('0.1.0', 'm2s', [], { slot: { row: 2, item: 0 } })], { slot: { row: 8, item: 1 }, lines: [4, 4] }), block('0.2', 'rest'), ]), -] +]) function cardTree(): NonNullable> { return diffTree(CARD_BLOCKS, diff({ blocks: { card: { change: 'mod' }, m2: { change: 'mod' }, m2s: { change: 'mod', own: true } } }), 2, () => true)! @@ -136,7 +139,7 @@ describe('挂在卡片子行上的子块', () => { }) it('上一处 / 下一处:要展开的是子块所在的那一行和它自己,不是卡片', () => { - expect(changeStops(CARD_BLOCKS, cardTree(), new Set()).map(stop => [stop.id, stop.ancestors])).toEqual([ + expect(changeStops(CARD_BLOCKS, placementOf(CARD_BLOCKS), cardTree(), new Set()).map(stop => [stop.id, stop.ancestors])).toEqual([ ['0.1.0', ['0/p8', '0.1', '0.1/p2', '0.1.0']], ]) }) @@ -144,7 +147,7 @@ describe('挂在卡片子行上的子块', () => { describe('changeStops', () => { it('新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后;修改块只算自己那几行', () => { - const stops = changeStops(TREE_BLOCKS, tree([removed('', 'c'), removed('a', undefined)]), new Set()) + const stops = changeStops(TREE_BLOCKS, placementOf(TREE_BLOCKS), tree([removed('', 'c'), removed('a', undefined)]), new Set()) expect(stops.map(stop => [stop.id, stop.change, stop.from, stop.to, stop.ancestors])).toEqual([ ['removed-1', 'del', 3, 3, ['0']], ['0.1', 'mod', 2, 2, ['0']], @@ -154,7 +157,7 @@ describe('changeStops', () => { }) it('被筛掉的块和它下面的不算', () => { - expect(changeStops(TREE_BLOCKS, tree(), new Set(['3', '3.0'])).map(stop => stop.id)).toEqual(['0.1']) + expect(changeStops(TREE_BLOCKS, placementOf(TREE_BLOCKS), tree(), new Set(['3', '3.0'])).map(stop => stop.id)).toEqual(['0.1']) }) }) diff --git a/vscode/webview-ui/src/codeReader/diffView.ts b/vscode/webview-ui/src/codeReader/diffView.ts index 5415cb0..25cf6d4 100644 --- a/vscode/webview-ui/src/codeReader/diffView.ts +++ b/vscode/webview-ui/src/codeReader/diffView.ts @@ -1,4 +1,5 @@ -import type { BlockView, ChangedBlock, DiffStats, DocumentDiff, RemovedView } from './types' +import type { Placement } from './placement' +import type { BlockView, CardView, ChangedBlock, DiffStats, DocumentDiff, RemovedView } from './types' import { inlineText } from './segments' import { unfoldToShow } from './viewState' @@ -63,7 +64,7 @@ export function statsParts(stats: DiffStats): { change: keyof DiffStats, text: s } /** 删掉的块收起时写的名字:属性表是第一行的值(函数名、变量名、导入的来源),一行译文是译文;只取第一行。 */ -export function removedTitle(block: BlockView): string { +export function removedTitle(block: CardView): string { const text = 'properties' in block ? block.properties[0] && ('markdown' in block.properties[0].value ? block.properties[0].value.markdown : inlineText(block.properties[0].value)) : 'markdown' in block.translation ? block.translation.markdown : inlineText(block.translation) @@ -140,8 +141,8 @@ export interface ChangeStop { ancestors: string[] } -/** 页面上的各处改动(见 ChangeStop);hidden 里的块(被筛掉的)连同子孙不算。 */ -export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden: ReadonlySet): ChangeStop[] { +/** 页面上的各处改动(见 ChangeStop,行号是文件里的,按 placement 换算);hidden 里的块(被筛掉的)连同子孙不算。 */ +export function changeStops(blocks: readonly BlockView[], placement: Placement, tree: DiffTree, hidden: ReadonlySet): ChangeStop[] { const stops: ChangeStop[] = [] /** 一层块:owner 是父块 id(顶层 ''),ancestorsOf 给出要让某个子块看得见得展开的,removedAncestors 是这一层删掉的块要展开的 */ const visit = (list: readonly BlockView[], owner: string, ancestorsOf: (block: BlockView) => string[], removedAncestors: string[]): void => { @@ -154,11 +155,13 @@ export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden for (const block of list) { const ancestors = ancestorsOf(block) const change = hidden.has(block.id) ? undefined : tree.changes.get(block.id) - if (change?.change === 'add' && change.outermost) - stops.push({ id: block.id, change: 'add', from: block.startLine, to: block.endLine, ancestors }) - if (change?.change === 'mod' && change.own) { - const own = block.children[0] ? Math.max(block.startLine, block.children[0].startLine - 1) : block.endLine - stops.push({ id: block.id, change: 'mod', from: block.startLine, to: own, ancestors }) + const place = placement.of(block.id) + if (place && change?.change === 'add' && change.outermost) + stops.push({ id: block.id, change: 'add', from: place.line, to: place.endLine, ancestors }) + if (place && change?.change === 'mod' && change.own) { + const first = block.children[0] && placement.of(block.children[0].id) + const own = first ? Math.max(place.line, first.line - 1) : place.endLine + stops.push({ id: block.id, change: 'mod', from: place.line, to: own, ancestors }) } if (change?.change === 'mod') visit(block.children, block.id, child => [...ancestors, ...unfoldToShow(block.id, child)], [...ancestors, block.id]) diff --git a/vscode/webview-ui/src/codeReader/displayNames.test.tsx b/vscode/webview-ui/src/codeReader/displayNames.test.tsx index 22ffff9..9d7ac46 100644 --- a/vscode/webview-ui/src/codeReader/displayNames.test.tsx +++ b/vscode/webview-ui/src/codeReader/displayNames.test.tsx @@ -3,17 +3,21 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { nameMap, sameShownName, shownName } from './displayNames' import { OccurrenceHighlight } from './occurrences' -import { SegmentText, TextMarksContext } from './SegmentText' +import { BlockIdContext, SegmentText, TextMarksContext } from './SegmentText' import { staticStore } from './store' const CURRENT_USER: ShownName = { offset: 40, length: 12, name: '当前用户', definition: true } const USE: ShownName = { offset: 90, length: 12, name: '当前用户' } const ADMIN: ShownName = { offset: 130, length: 5, name: '管理员' } -function render(segments: Inline[], names: ShownName[]): string { +/** 画一段文字:它在块 b 的卡片里,块 b 的开头在文件里的下标是 base */ +function render(segments: Inline[], names: ShownName[], base = 0): string { + const placement = { of: (id: string) => id === 'b' ? { offset: base, line: 0, endLine: 0 } : undefined } return renderToStaticMarkup( - - + + + + , ) } @@ -29,10 +33,18 @@ describe('页面上的中文名', () => { it('只认下标和长度都对得上的名字:没下标的译词、长度不一样的(名字改长了)不换', () => { const names = nameMap([USE]) - expect(shownName({ text: 'current_user', role: 'variable', offset: 90 }, names)).toBe(USE) - expect(shownName({ text: 'current_userx', role: 'variable', offset: 90 }, names)).toBeUndefined() - expect(shownName({ text: 'current_user', role: 'variable' }, names)).toBeUndefined() - expect(shownName({ text: 'current_user', role: 'variable', offset: 91 }, names)).toBeUndefined() + expect(shownName({ text: 'current_user', role: 'variable', offset: 90 }, 0, names)).toBe(USE) + expect(shownName({ text: 'current_userx', role: 'variable', offset: 90 }, 0, names)).toBeUndefined() + expect(shownName({ text: 'current_user', role: 'variable' }, 0, names)).toBeUndefined() + expect(shownName({ text: 'current_user', role: 'variable', offset: 91 }, 0, names)).toBeUndefined() + }) + + it('段里的下标从所在那块的开头算:加上那块的开头才和中文名的下标(文件里的)对上', () => { + const names = nameMap([USE]) + expect(shownName({ text: 'current_user', role: 'variable', offset: 10 }, 80, names)).toBe(USE) + expect(shownName({ text: 'current_user', role: 'variable', offset: 90 }, 80, names)).toBeUndefined() + const html = render([{ text: 'current_user', role: 'variable', offset: 10 }], [USE], 80) + expect(html).toContain('当前用户') }) it('用到的地方显示中文名,悬停写「原名 x」,下标照旧(⌘ 点击跳到定义、单击高亮都认它)', () => { diff --git a/vscode/webview-ui/src/codeReader/displayNames.ts b/vscode/webview-ui/src/codeReader/displayNames.ts index 6c79a8c..1e41f1a 100644 --- a/vscode/webview-ui/src/codeReader/displayNames.ts +++ b/vscode/webview-ui/src/codeReader/displayNames.ts @@ -7,9 +7,9 @@ export function nameMap(names: readonly ShownName[]): NameMap { return new Map(names.map(name => [name.offset, name])) } -/** 这段名字显示成什么中文名:照抄自源码、下标和长度都对得上才算。 */ -export function shownName(segment: Segment, names: NameMap): ShownName | undefined { - const shown = segment.offset === undefined ? undefined : names.get(segment.offset) +/** 这段名字显示成什么中文名:照抄自源码、下标和长度都对得上才算。段里的下标从所在那块的开头算,那块的开头在文件里的下标是 base。 */ +export function shownName(segment: Segment, base: number, names: NameMap): ShownName | undefined { + const shown = segment.offset === undefined ? undefined : names.get(base + segment.offset) return shown?.length === segment.text.length ? shown : undefined } diff --git a/vscode/webview-ui/src/codeReader/editing.test.ts b/vscode/webview-ui/src/codeReader/editing.test.ts index be4c378..83ec9b4 100644 --- a/vscode/webview-ui/src/codeReader/editing.test.ts +++ b/vscode/webview-ui/src/codeReader/editing.test.ts @@ -1,17 +1,20 @@ +import type { WrittenBlock } from './testBlocks' import type { BlockView } from './types' import { describe, expect, it } from 'vitest' import { coveredByEditing, startEditing } from './editing' +import { placementOf } from './placement' import { revealPlan, uncoveredPlan } from './reveal' +import { placed } from './testBlocks' -function block(id: string, kind: string, [startLine, endLine]: [number, number], children: BlockView[] = []): BlockView { - return { id, kind, startLine, endLine, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties: [] } +function block(id: string, kind: string, lines: [number, number], children: WrittenBlock[] = []): WrittenBlock { + return { id, anchor: id, kind, lines, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties: [] } } /** * 编辑范围是块的完整源码:库规则的卡片(1)是整个类(2–12 行),两个字段中间写着的方法(1.0)和它的主体(1.0.0)、 * 类末尾的方法(1.1)都在这段里;普通的类(2)同样盖住它的方法(2.0);`} else {` 写在一行时 if(3)和 else(4)共用第 20 行。 */ -const BLOCKS = [ +const BLOCKS = placed([ block('0', 'import', [0, 0]), block('1', 'sqlalchemy.table', [2, 12], [ block('1.0', 'function', [5, 6], [block('1.0.0', 'return', [6, 6])]), @@ -20,7 +23,8 @@ const BLOCKS = [ block('2', 'class', [14, 16], [block('2.0', 'function', [15, 16])]), block('3', 'if', [18, 20], [block('3.0', 'return', [19, 19])]), block('4', 'else', [20, 22], [block('4.0', 'return', [21, 21])]), -] +]) +const PLACEMENT = placementOf(BLOCKS) describe('同一行不会同时在两处能改', () => { it('块编辑时它的子孙都不画(代码都在它的编辑框里),跳到它们时改闪它', () => { @@ -38,29 +42,31 @@ describe('同一行不会同时在两处能改', () => { }) 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.0']) - expect(startEditing(BLOCKS, ['0', '3'], '4')).toEqual(['0', '4']) + expect(startEditing(PLACEMENT, ['1.0', '1.1', '2'], '1')).toEqual(['2', '1']) + expect(startEditing(PLACEMENT, ['1', '2'], '1.0')).toEqual(['2', '1.0']) + expect(startEditing(PLACEMENT, ['2'], '2.0')).toEqual(['2.0']) + expect(startEditing(PLACEMENT, ['0', '3'], '4')).toEqual(['0', '4']) }) 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', '4', '1.1']) - editing = startEditing(BLOCKS, editing, id) + editing = startEditing(PLACEMENT, 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) + for (const b of shown.filter(other => other !== a)) { + const [x, y] = [PLACEMENT.of(a.id)!, PLACEMENT.of(b.id)!] + expect(x.line > y.endLine || y.line > x.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) + const plain = revealPlan(BLOCKS, PLACEMENT, 15, 999) expect(plain && uncoveredPlan(plain, covered)).toEqual(plain) }) }) diff --git a/vscode/webview-ui/src/codeReader/editing.ts b/vscode/webview-ui/src/codeReader/editing.ts index 3058d30..b70633a 100644 --- a/vscode/webview-ui/src/codeReader/editing.ts +++ b/vscode/webview-ui/src/codeReader/editing.ts @@ -1,3 +1,4 @@ +import type { Place, Placement } from './placement' import type { BlockView } from './types' /** @@ -6,10 +7,8 @@ import type { BlockView } from './types' * ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑。 */ -type Lines = Pick - -function overlaps(a: Lines, b: Lines): boolean { - return a.startLine <= b.endLine && b.startLine <= a.endLine +function overlaps(a: Place, b: Place): boolean { + return a.line <= b.endLine && b.line <= a.endLine } /** 正在编辑的块的子孙不画 → 盖住它的那块(页面上画着的、正在编辑的祖先):跳到定义落在被盖住的块上时改闪它。 */ @@ -33,20 +32,12 @@ export function coveredByEditing(blocks: readonly BlockView[], editing: Readonly return covered } -function* allBlocks(blocks: readonly BlockView[]): Generator { - 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) +/** 点了 id 这块的「编辑」之后正在编辑的块:加上它,编辑范围(块在文件里的首尾行,见 placement)和它重叠的别的块退出编辑。 */ +export function startEditing(placement: Placement, editing: readonly string[], id: string): string[] { + const target = placement.of(id) const others = editing.filter((other) => { - const block = byId.get(other) - return other !== id && !(target && block && overlaps(target, block)) + const place = placement.of(other) + return other !== id && !(target && place && overlaps(target, place)) }) return [...others, id] } diff --git a/vscode/webview-ui/src/codeReader/filter.ts b/vscode/webview-ui/src/codeReader/filter.ts index dce7b29..c3cd7df 100644 --- a/vscode/webview-ui/src/codeReader/filter.ts +++ b/vscode/webview-ui/src/codeReader/filter.ts @@ -23,7 +23,7 @@ export interface Facet { * 块被不被筛掉:它有的属性里有一个的值被去掉了就筛掉。也就是同一属性里勾着的值满足一个就行、不同属性要同时满足; * 块没有的属性(条件、返回块没有访问级别)不受那个属性的筛选影响。 */ -export function excludedBy(block: BlockView, excluded: Excluded): boolean { +export function excludedBy(block: Pick, excluded: Excluded): boolean { return Object.entries(block.attributes).some(([attribute, value]) => excluded[attribute]?.includes(value) ?? false) } diff --git a/vscode/webview-ui/src/codeReader/itemHeight.test.ts b/vscode/webview-ui/src/codeReader/itemHeight.test.ts index 4499f1a..518e6ed 100644 --- a/vscode/webview-ui/src/codeReader/itemHeight.test.ts +++ b/vscode/webview-ui/src/codeReader/itemHeight.test.ts @@ -1,14 +1,14 @@ import type { BlockView } from './types' import { describe, expect, it } from 'vitest' import { estimatedHeight } from './itemHeight' +import { NO_SPAN } from './testBlocks' function block(id: string, kind: string, extra: Partial = {}): BlockView { return { id, anchor: id, kind, - startLine: 0, - endLine: 0, + span: NO_SPAN, attributes: { kind }, concepts: [], children: [], diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts index fbff43b..f9206a9 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.test.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.test.ts @@ -36,14 +36,14 @@ function find(occ: OccurrenceHighlight, offset: number, text: string): void { describe('occurrences:哪些段画高亮', () => { it('段在源码里的范围和一处出现重叠就画,边界相接不算,没有下标的不画', () => { const occurrences = [at(10, 18)] - expect(segmentOccurrence(name('ALPHABET', 10), occurrences)).toBe('normal') - expect(segmentOccurrence(name('x', 2), occurrences)).toBeUndefined() - expect(segmentOccurrence(name('ab', 8), occurrences)).toBeUndefined() - expect(segmentOccurrence(name('ab', 18), occurrences)).toBeUndefined() - expect(segmentOccurrence(name('ab', 17), occurrences)).toBe('normal') - expect(segmentOccurrence({ text: '常量', role: 'storage' }, occurrences)).toBeUndefined() + expect(segmentOccurrence(name('ALPHABET', 10), 0, occurrences)).toBe('normal') + expect(segmentOccurrence(name('x', 2), 0, occurrences)).toBeUndefined() + expect(segmentOccurrence(name('ab', 8), 0, occurrences)).toBeUndefined() + expect(segmentOccurrence(name('ab', 18), 0, occurrences)).toBeUndefined() + expect(segmentOccurrence(name('ab', 17), 0, occurrences)).toBe('normal') + expect(segmentOccurrence({ text: '常量', role: 'storage' }, 0, occurrences)).toBeUndefined() // 整段字符串比名字长:出现落在字符串里面也算 - expect(segmentOccurrence({ text: '\'./util\'', role: 'string', offset: 9 }, occurrences)).toBe('normal') + expect(segmentOccurrence({ text: '\'./util\'', role: 'string', offset: 9 }, 0, occurrences)).toBe('normal') }) it('出现很多、乱序、有长有短还互相重叠时,结果和逐处比一样', () => { @@ -63,7 +63,7 @@ describe('occurrences:哪些段画高亮', () => { }) for (let offset = 0; offset < 2020; offset += 3) { const segment = name('x'.repeat(1 + (offset % 7)), offset) - expect(segmentOccurrence(segment, occurrences), `offset ${offset}`).toBe(brute(segment, occurrences)) + expect(segmentOccurrence(segment, 0, occurrences), `offset ${offset}`).toBe(brute(segment, occurrences)) } }) @@ -71,18 +71,28 @@ describe('occurrences:哪些段画高亮', () => { expect(occurrenceStyle('write')).toBe('strong') expect(occurrenceStyle('read')).toBe('normal') expect(occurrenceStyle('text')).toBe('normal') - expect(segmentOccurrence(name('a', 0), [at(0, 1, 'text')])).toBe('normal') - expect(segmentOccurrence(name('a', 0), [at(0, 1, 'read'), at(0, 1, 'write')])).toBe('strong') + expect(segmentOccurrence(name('a', 0), 0, [at(0, 1, 'text')])).toBe('normal') + expect(segmentOccurrence(name('a', 0), 0, [at(0, 1, 'read'), at(0, 1, 'write')])).toBe('strong') }) it('讲别处名字的段(refers,写在字符串里的参数名)按那个名字算:那个名字的出现盖住它就画、能是当前那一处,自己照抄的位置不算', () => { const param: Segment = { text: 'user_id', role: 'variable', offset: 40, refers: { startIndex: 100, endIndex: 107 } } - expect(sourceOf(param)).toEqual({ start: 100, end: 107 }) - expect(segmentOccurrence(param, [at(100, 107, 'write')])).toBe('strong') - expect(segmentOccurrence(param, [at(40, 47)])).toBeUndefined() - expect(isCurrent(param, { name: 'user_id', occurrences: [at(100, 107)], current: 0 })).toBe(true) - expect(sourceOf(name('x', 3))).toEqual({ start: 3, end: 4 }) - expect(sourceOf({ text: '常量', role: 'storage' })).toBeUndefined() + expect(sourceOf(param, 0)).toEqual({ start: 100, end: 107 }) + expect(segmentOccurrence(param, 0, [at(100, 107, 'write')])).toBe('strong') + expect(segmentOccurrence(param, 0, [at(40, 47)])).toBeUndefined() + expect(isCurrent(param, 0, { name: 'user_id', occurrences: [at(100, 107)], current: 0 })).toBe(true) + expect(sourceOf(name('x', 3), 0)).toEqual({ start: 3, end: 4 }) + expect(sourceOf({ text: '常量', role: 'storage' }, 0)).toBeUndefined() + }) + + it('段里的下标从所在那块的开头算:加上那块的开头(base)才是文件里的,和各处出现比、认当前那一处都按文件里的', () => { + const param: Segment = { text: 'user_id', role: 'variable', offset: 4, refers: { startIndex: 10, endIndex: 17 } } + expect(sourceOf(param, 1000)).toEqual({ start: 1010, end: 1017 }) + expect(sourceOf(name('x', 3), 1000)).toEqual({ start: 1003, end: 1004 }) + expect(segmentOccurrence(name('x', 3), 1000, [at(1003, 1004, 'write')])).toBe('strong') + expect(segmentOccurrence(name('x', 3), 1000, [at(3, 4, 'write')])).toBeUndefined() + expect(isCurrent(param, 1000, { name: 'user_id', occurrences: [at(1010, 1017)], current: 0 })).toBe(true) + expect(isCurrent(param, 0, { name: 'user_id', occurrences: [at(1010, 1017)], current: 0 })).toBe(false) }) it('真实的 Python 译文和属性表:定义处按写入、用到的地方按读取画;点分模块名每一节各按自己的范围认', async () => { @@ -98,7 +108,7 @@ describe('occurrences:哪些段画高亮', () => { const py = createPythonAdapter(WASM_DIR) const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator)) const highlighted = (occurrences: Occurrence[]): string[] => allSegments(views).flatMap((segment) => { - const style = segmentOccurrence(segment, occurrences) + const style = segmentOccurrence(segment, 0, occurrences) return style ? [`${segment.text}:${style}`] : [] }) const range = (needle: string, kind: Occurrence['kind'], from = 0): Occurrence => { @@ -318,10 +328,10 @@ describe('occurrences:用键盘在各处出现之间跳', () => { it('当前那一处:和它重叠的段描边,别的出现、没下标的段不描', () => { const highlight = { name: 'ALPHABET', occurrences: [at(10, 18), at(40, 48)], current: 1 } - expect(isCurrent(name('ALPHABET', 40), highlight)).toBe(true) - expect(isCurrent(name('ALPHABET', 10), highlight)).toBe(false) - expect(isCurrent({ text: 'ALPHABET', role: 'plain' }, highlight)).toBe(false) - expect(isCurrent(name('ALPHABET', 40), { ...highlight, current: undefined })).toBe(false) + expect(isCurrent(name('ALPHABET', 40), 0, highlight)).toBe(true) + expect(isCurrent(name('ALPHABET', 10), 0, highlight)).toBe(false) + expect(isCurrent({ text: 'ALPHABET', role: 'plain' }, 0, highlight)).toBe(false) + expect(isCurrent(name('ALPHABET', 40), 0, { ...highlight, current: undefined })).toBe(false) }) it('能不能用键盘跳:有高亮、页面有焦点、焦点不在输入框里;第一次都告诉扩展端,之后变了才告诉', () => { diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index 9227634..b47ffbc 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -8,11 +8,11 @@ export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle { } /** - * 一段文字画不画高亮、画哪种:它讲的名字(sourceOf)和哪处出现重叠就画, + * 一段文字画不画高亮、画哪种:它讲的名字(sourceOf,所在那块的开头在文件里的下标是 base)和哪处出现重叠就画, * 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。 */ -export function segmentOccurrence(segment: Segment, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined { - const source = sourceOf(segment) +export function segmentOccurrence(segment: Segment, base: number, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined { + const source = sourceOf(segment, base) if (!source) return undefined const { sorted, longest } = occurrenceIndex(occurrences) @@ -66,23 +66,24 @@ function firstStartAtOrAfter(sorted: readonly Occurrence[], at: number): number /** * 段讲的是源码里哪个名字,[start, end):讲别处名字的段(refers,写在字符串里的参数名)是那个名字,照抄自源码的段是它自己占的 - * [offset, offset + 字数);没有下标的段(译词、提示词)没有。跳到定义、单击高亮、各处出现画在哪都按它。 + * [offset, offset + 字数);没有下标的段(译词、提示词)没有。段里的下标从所在那块的开头算,base 是那块的开头在文件里的下标 + * (给 0 就是块里的下标)。跳到定义、单击高亮、各处出现画在哪都按它。 */ -export function sourceOf(segment: Segment): { start: number, end: number } | undefined { +export function sourceOf(segment: Segment, base: number): { start: number, end: number } | undefined { if (segment.refers) - return { start: segment.refers.startIndex, end: segment.refers.endIndex } - return segment.offset === undefined ? undefined : { start: segment.offset, end: segment.offset + segment.text.length } + return { start: base + segment.refers.startIndex, end: base + segment.refers.endIndex } + return segment.offset === undefined ? undefined : { start: base + segment.offset, end: base + segment.offset + segment.text.length } } -/** 段讲的名字(sourceOf)和这处出现重叠;没有下标的段不算。 */ -export function overlaps(segment: Segment, occurrence: Occurrence): boolean { - const source = sourceOf(segment) +/** 段讲的名字(sourceOf,所在那块的开头是 base)和这处出现重叠;没有下标的段不算。 */ +export function overlaps(segment: Segment, base: number, occurrence: Occurrence): boolean { + const source = sourceOf(segment, base) return source !== undefined && occurrence.start < source.end && source.start < occurrence.end } -/** 文字里(连框里)有段和这处出现重叠。 */ -export function showsOccurrence(pieces: readonly Inline[], occurrence: Occurrence): boolean { - return pieces.some(piece => 'role' in piece ? overlaps(piece, occurrence) : showsOccurrence(piece.segments, occurrence)) +/** 文字里(连框里)有段和这处出现重叠;文字所在那块的开头是 base。 */ +export function showsOccurrence(pieces: readonly Inline[], base: number, occurrence: Occurrence): boolean { + return pieces.some(piece => 'role' in piece ? overlaps(piece, base, occurrence) : showsOccurrence(piece.segments, base, occurrence)) } /** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */ @@ -126,10 +127,10 @@ export interface Highlight { current?: number } -/** 段是不是当前那一处出现(点中的、用键盘跳到的)。 */ -export function isCurrent(segment: Segment, highlight: Highlight): boolean { +/** 段是不是当前那一处出现(点中的、用键盘跳到的);段所在那块的开头是 base。 */ +export function isCurrent(segment: Segment, base: number, highlight: Highlight): boolean { const current = highlight.current === undefined ? undefined : highlight.occurrences[highlight.current] - return current !== undefined && overlaps(segment, current) + return current !== undefined && overlaps(segment, base, current) } /** 筛选栏上名字后面写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */ diff --git a/vscode/webview-ui/src/codeReader/pageLayout.test.ts b/vscode/webview-ui/src/codeReader/pageLayout.test.ts index c42d28a..22c0cc3 100644 --- a/vscode/webview-ui/src/codeReader/pageLayout.test.ts +++ b/vscode/webview-ui/src/codeReader/pageLayout.test.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import type { Placement } from './placement' import type { BlockBox } from './scrollPosition' import { afterEach, describe, expect, it } from 'vitest' import { PageLayout } from './pageLayout' @@ -31,8 +32,13 @@ function level(owner: string | undefined, ...children: HTMLElement[]): HTMLEleme return div('cr-level', owner === undefined ? {} : { 'data-owner': owner }, div('guide'), div('column', {}, ...children)) } +/** 各块在文件里的起始行(页面上不记行号,按块 id 从文档里各块的位置取) */ +const LINES: Record = {} +const PLACEMENT: Placement = { of: id => id in LINES ? { offset: 0, line: LINES[id], endLine: LINES[id] } : undefined } + function card(id: string, line: number, top: number, bottom: number, editing = false): HTMLElement { - return place(div('cr-block', { 'data-id': id, 'data-line': String(line), ...(editing && { 'data-editing': '' }) }), top, bottom) + LINES[id] = line + return place(div('cr-block', { 'data-id': id, ...(editing && { 'data-editing': '' }) }), top, bottom) } /** @@ -42,9 +48,9 @@ function page(): { root: HTMLElement, cards: BlockBox[] } { const root = document.createElement('div') root.append(level( undefined, - place(div('cr-item', { 'data-line': '0' }, card('0', 0, 0, 100), place(level('0', card('0.0', 2, 120, 300), card('0.1', 8, 320, 500)), 110, 900)), 0, 1000), - place(div('cr-item', { 'data-line': '20' }, card('1', 20, 1010, 1500, true)), 1010, 1500), - place(div('cr-item', { 'data-line': '40' }, card('2', 40, 1510, 2000)), 1510, 2000), + place(div('cr-item', {}, card('0', 0, 0, 100), place(level('0', card('0.0', 2, 120, 300), card('0.1', 8, 320, 500)), 110, 900)), 0, 1000), + place(div('cr-item', {}, card('1', 20, 1010, 1500, true)), 1010, 1500), + place(div('cr-item', {}, card('2', 40, 1510, 2000)), 1510, 2000), )) document.body.append(root) const cards = [[0, 0, 100], [2, 120, 300], [8, 320, 500], [20, 1010, 1500], [40, 1510, 2000]].map(([line, top, bottom]) => ({ line, top, bottom })) @@ -60,7 +66,7 @@ describe('卡片位置量过就记着', () => { it('滚动位置、包含某一行的块:和把全部卡片量一遍算出来的一样', () => { const { root, cards } = page() const layout = new PageLayout() - layout.attach(root, null) + layout.attach(root, null, () => PLACEMENT) for (const y of [0, 50, 100, 310, 950, 1200, 1999, 2500]) { const found = layout.anchorAt(y) expect(found && { line: found.line, offset: found.offset }, `y ${y}`).toEqual((({ line, offset }) => ({ line, offset }))(anchorAt(cards, y)!)) @@ -74,7 +80,7 @@ describe('卡片位置量过就记着', () => { it('钉住的卡片:只算和顶上那一段相交的顶层块,在编辑的不钉;外层卡片的下沿算到它的子层', () => { const { root } = page() const layout = new PageLayout() - layout.attach(root, null) + layout.attach(root, null, () => PLACEMENT) window.scrollTo(0, 0) const scopes = layout.stickyScopes(48) expect(scopes.map(scope => [scope.id, scope.depth, scope.top, scope.bottom])).toEqual([['0', 0, 0, 900]]) @@ -83,7 +89,7 @@ describe('卡片位置量过就记着', () => { it('滚动时不再量:同一份内容问几次都只量一次;只量用到的那几段;内容变了才重量', async () => { const { root } = page() const layout = new PageLayout() - layout.attach(root, null) + layout.attach(root, null, () => PLACEMENT) layout.anchorAt(0) // 三段顶层块的外框 + 第 0、1 段里的卡片和子层 const first = reads @@ -101,7 +107,7 @@ describe('卡片位置量过就记着', () => { it('要滚过去的:目标所在的顶层块连同上下几屏之内的先照常排版,过两帧放回去', async () => { const { root } = page() const layout = new PageLayout() - layout.attach(root, null) + layout.attach(root, null, () => PLACEMENT) const items = [...root.querySelectorAll('.cr-item')] expect(layout.cardForLine(40)?.line).toBe(40) expect(items.map(item => item.hasAttribute('data-settle'))).toEqual([true, true, true]) diff --git a/vscode/webview-ui/src/codeReader/pageLayout.ts b/vscode/webview-ui/src/codeReader/pageLayout.ts index 972a783..cfeeda1 100644 --- a/vscode/webview-ui/src/codeReader/pageLayout.ts +++ b/vscode/webview-ui/src/codeReader/pageLayout.ts @@ -1,5 +1,7 @@ +import type { Placement } from './placement' import type { BlockBox, ScrollAnchor } from './scrollPosition' import type { StickyScope } from './stickyScroll' +import { NO_PLACEMENT } from './placement' import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition' /** 一张卡片量出来的位置(文档坐标):BlockBox 之外,块 id、外面套着几张卡片、连同画出来的子块的下沿、左右边,在不在编辑。 */ @@ -28,8 +30,8 @@ export function depthOf(card: Element): number { return depth } -/** 一段顶层块里的卡片(BlockCard 带 data-line;删掉的块那张没有),按页面先后;下沿算到它名下画出来的子层(带 data-owner)的最后 */ -function measureCards(item: HTMLElement): CardBox[] { +/** 一段顶层块里的卡片(这份文档里的块;删掉的块那张不算,它在文件里没有位置),按页面先后;起始行按 placement 换算,下沿算到它名下画出来的子层(带 data-owner)的最后 */ +function measureCards(item: HTMLElement, placement: Placement): CardBox[] { const x = window.scrollX const y = window.scrollY const bottoms = new Map() @@ -37,13 +39,16 @@ function measureCards(item: HTMLElement): CardBox[] { const owner = level.dataset.owner ?? '' bottoms.set(owner, Math.max(bottoms.get(owner) ?? Number.NEGATIVE_INFINITY, level.getBoundingClientRect().bottom + y)) } - return Array.from(item.querySelectorAll('.cr-block[data-line]'), (card) => { + return Array.from(item.querySelectorAll('.cr-block[data-id]')).flatMap((card) => { const id = card.dataset.id ?? '' + const place = placement.of(id) + if (!place) + return [] const rect = card.getBoundingClientRect() const bottom = rect.bottom + y - return { + return [{ id, - line: Number(card.dataset.line), + line: place.line, top: rect.top + y, bottom, scopeBottom: Math.max(bottom, bottoms.get(id) ?? bottom), @@ -51,7 +56,7 @@ function measureCards(item: HTMLElement): CardBox[] { right: rect.right + x, depth: depthOf(card), editing: card.hasAttribute('data-editing'), - } + }] }) } @@ -78,10 +83,12 @@ function itemOfLine(items: readonly ItemBox[], line: number): number { * 页面上卡片的位置和筛选栏的高度,量过就记着,滚动时只在记着的里面按顶层块二分找,不再每帧量全部卡片(量位置会逼浏览器先排版)。 * 顶层的块(.cr-item)先整段量,里面的卡片用到哪段才量哪段(屏幕外的段可能没排版,量它要先排一次)。坐标都是文档坐标(视口坐标 + 滚动位置),滚动不改它。 * 页面内容的结构变了(MutationObserver)、大小变了(ResizeObserver)、窗口大小变了,或页面重画了(invalidate,同一段脚本里改完马上要量的)才作废,下次用时重量。 + * 卡片的起始行按 placement(这一版文档里各块在文件里的位置)换算;换了一版文档页面跟着重画,记着的一并作废。 */ export class PageLayout { private root: HTMLElement | null = null private bar: HTMLElement | null = null + private placement: () => Placement = () => NO_PLACEMENT private items: ItemBox[] | undefined private cards = new Map() private barHeight: number | undefined @@ -89,14 +96,15 @@ export class PageLayout { private settling: HTMLElement[] = [] private release = 0 - /** 量 root(块树所在的页面内容)里的卡片,筛选栏是 bar;返回停止观察的函数 */ - attach(root: HTMLElement, bar: HTMLElement | null): () => void { + /** 量 root(块树所在的页面内容)里的卡片,筛选栏是 bar,各块在文件里的位置从 placement 取;返回停止观察的函数 */ + attach(root: HTMLElement, bar: HTMLElement | null, placement: () => Placement): () => void { this.root = root this.bar = bar + this.placement = placement this.invalidate() const invalidate = (): void => this.invalidate() const mutations = new MutationObserver(invalidate) - mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-line', 'data-editing', 'data-id'] }) + mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-editing', 'data-id'] }) const sizes = new ResizeObserver(invalidate) sizes.observe(root) if (bar) @@ -109,6 +117,7 @@ export class PageLayout { if (this.root === root) { this.root = null this.bar = null + this.placement = () => NO_PLACEMENT this.invalidate() } } @@ -131,11 +140,17 @@ export class PageLayout { // 顶层那一层:块树最外面的 .cr-level 的右栏,里面的 .cr-item 一块一段 const column = this.root?.querySelector(':scope > .cr-level > :last-child') const y = window.scrollY + const placement = this.placement() this.items = Array.from(column?.children ?? []).flatMap((el) => { if (!(el instanceof HTMLElement) || !el.classList.contains('cr-item')) return [] + // 这段的起始行是打头那张卡片那块的 + const id = el.querySelector(':scope > .cr-block')?.dataset.id + const place = id === undefined ? undefined : placement.of(id) + if (!place) + return [] const rect = el.getBoundingClientRect() - return [{ el, top: rect.top + y, bottom: rect.bottom + y, line: Number(el.dataset.line) }] + return [{ el, top: rect.top + y, bottom: rect.bottom + y, line: place.line }] }) } return this.items @@ -144,7 +159,7 @@ export class PageLayout { private cardsOf(item: ItemBox): CardBox[] { let cards = this.cards.get(item.el) if (!cards) { - cards = measureCards(item.el) + cards = measureCards(item.el, this.placement()) this.cards.set(item.el, cards) } return cards diff --git a/vscode/webview-ui/src/codeReader/pageState.ts b/vscode/webview-ui/src/codeReader/pageState.ts index 4984f25..5b4d106 100644 --- a/vscode/webview-ui/src/codeReader/pageState.ts +++ b/vscode/webview-ui/src/codeReader/pageState.ts @@ -2,11 +2,13 @@ import type { SourceText } from './blockSource' import type { BlockTreeState } from './BlockTree' import type { ChangeStop, DiffTree, FoldedRuns } from './diffView' import type { FilterView } from './filter' +import type { Placement } from './placement' import type { ReadableStore } from './store' import type { BlockView, CallTree, ContractMiss, DocumentDiff, DocumentMessage, Excluded, KindBadge, Reveal, SourceColors } from './types' import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView' import { coveredByEditing } from './editing' import { applyFilter, excludedBy } from './filter' +import { placementOf } from './placement' import { createStore } from './store' import { collapsedIds, foldsByCard } from './viewState' @@ -59,6 +61,8 @@ export interface Page extends PageInputs { covered: ReadonlyMap /** 「只看改动」折起来的块 */ folded: FoldedRuns | undefined + /** 各块在文件里的位置(见 placementOf):块里的位置都从块的开头算,和文件里的位置打交道时按它换算 */ + placement: Placement /** 页面上没画出来的块:被筛掉的,和「只看改动」折起来的 */ unshown: ReadonlySet stops: ChangeStop[] @@ -101,7 +105,7 @@ export function createPageStore(initial: Pick): ? foldUnchanged(blocks, changes, block => !filter.hidden.has(block.id) && !covered.has(block.id), openedRuns, keep) : undefined) const unshownOf = memoizeOne((filter: FilterView, folded: FoldedRuns | undefined) => folded ? new Set([...filter.hidden, ...folded.folded]) : filter.hidden) - const stopsOf = memoizeOne((blocks: BlockView[], changes: DiffTree | undefined, filter: FilterView) => changes ? changeStops(blocks, changes, filter.hidden) : []) + const stopsOf = memoizeOne((blocks: BlockView[], placement: Placement, changes: DiffTree | undefined, filter: FilterView) => changes ? changeStops(blocks, placement, changes, filter.hidden) : []) let folds: ReadonlyMap> | undefined const foldsOf = memoizeOne((collapsed: ReadonlySet) => { folds = foldsByCard(collapsed, folds) @@ -114,6 +118,7 @@ export function createPageStore(initial: Pick): const treeOf = memoizeOne(( version: number, sourceText: SourceText, + placement: Placement, editing: ReadonlySet, focusId: string | null, counts: ReadonlyMap, @@ -125,7 +130,7 @@ export function createPageStore(initial: Pick): changes: DiffTree | undefined, folded: FoldedRuns | undefined, ): BlockTreeState => ({ - edit: { version, sourceText, editing, focusId }, + edit: { version, sourceText, placement, editing, focusId }, callTree: { counts, ...(openAnchor !== undefined && { openAnchor }) }, collapsed, folds, @@ -139,6 +144,7 @@ export function createPageStore(initial: Pick): const derive = (inputs: PageInputs): Page => { const { doc } = inputs const blocks = doc?.blocks ?? NO_BLOCKS + const placement = placementOf(blocks) const version = doc?.version ?? 0 const changes = doc ? changesOf(blocks, version, doc.error !== undefined, inputs.diff, inputs.excluded) : undefined const collapsed = collapsedOf(blocks, inputs.folds, changes) @@ -154,9 +160,10 @@ export function createPageStore(initial: Pick): filter, covered, folded, + placement, unshown: unshownOf(filter, folded), - stops: stopsOf(blocks, changes, filter), - tree: treeOf(version, sourceTextOf(doc?.source ?? '', doc?.colors ?? NO_COLORS), editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openAnchor, collapsed, foldsOf(collapsed), filter, covered, changes, folded), + stops: stopsOf(blocks, placement, changes, filter), + tree: treeOf(version, sourceTextOf(doc?.source ?? '', doc?.colors ?? NO_COLORS), placement, editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openAnchor, collapsed, foldsOf(collapsed), filter, covered, changes, folded), } } diff --git a/vscode/webview-ui/src/codeReader/placement.test.ts b/vscode/webview-ui/src/codeReader/placement.test.ts new file mode 100644 index 0000000..09046e1 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/placement.test.ts @@ -0,0 +1,84 @@ +import type { BlockView as HostBlockView } from '../../../src/codeReader/blockView' +import type { BlockView } from './types' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../../../src/codeReader/blockView' +import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks' +import { createPythonAdapter } from '../../../src/codeReader/languages/python' +import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' +import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' +import { placementOf } from './placement' + +const py = createPythonAdapter(WASM_DIR) + +async function views(source: string): Promise { + return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator)) +} + +function flatten(list: T[]): T[] { + return list.flatMap(block => [block, ...flatten(block.children)]) +} + +function byId(blocks: BlockView[]): Map { + return new Map(flatten(blocks).map(block => [block.id, block])) +} + +/** 一块自己的样子(不连子块):子块变了不算它变了 */ +function own({ children: _, ...rest }: BlockView): Omit { + return rest +} + +const SOURCE = [ + 'import os', + '', + 'def helper(value):', + ' return value + 1', + '', + 'class Store:', + ' def load(self, key):', + ' if key:', + ' return os.getenv(key)', + ' return None', + '', + 'LIMIT = 3', +].join('\n') + +describe('块在文件里的位置:扩展端记成相对的,页面累加回来', () => { + it('每块累加出来的首行、尾行、开头的下标同扩展端的一样;块里的下标加上块的开头就是文件里的', async () => { + const host = await views(SOURCE) + const placement = placementOf(pageBlocks(host, SOURCE)) + const lineStarts = SOURCE.split('\n').map((_, line, lines) => lines.slice(0, line).join('\n').length + (line > 0 ? 1 : 0)) + for (const block of flatten(host)) { + expect(placement.of(block.id), block.id).toEqual({ offset: lineStarts[block.startLine], line: block.startLine, endLine: block.endLine }) + } + const page = byId(pageBlocks(host, SOURCE)) + for (const block of flatten(host).filter(block => block.defines)) { + const base = placement.of(block.id)!.offset + const defines = page.get(block.id)!.defines! + expect(SOURCE.slice(base + defines.startIndex, base + defines.endIndex), block.id).toBe(SOURCE.slice(block.defines!.startIndex, block.defines!.endIndex)) + } + }) + + it('靠前的一块里改了一个字:只有那块变了,包着它的块只是长了一个字,后面的块发出去一模一样', async () => { + const edited = SOURCE.replace('value + 1', 'value + 10') + const before = byId(pageBlocks(await views(SOURCE), SOURCE)) + const after = byId(pageBlocks(await views(edited), edited)) + const changed = [...after.keys()].filter(id => JSON.stringify(own(after.get(id)!)) !== JSON.stringify(own(before.get(id)!))) + expect(changed).toEqual(['1', '1.0']) + expect(own(after.get('1')!)).toEqual({ ...own(before.get('1')!), span: { ...before.get('1')!.span, length: before.get('1')!.span.length + 1 } }) + }) + + it('上面多了几行:只有紧跟在后面的那块离前一块远了,其余块发出去一模一样,累加出来的位置都往后挪', async () => { + const shifted = SOURCE.replace('import os\n', 'import os\n\n\n') + const old = pageBlocks(await views(SOURCE), SOURCE) + const page = pageBlocks(await views(shifted), shifted) + const before = byId(old) + const after = byId(page) + const changed = [...after.keys()].filter(id => JSON.stringify(own(after.get(id)!)) !== JSON.stringify(own(before.get(id)!))) + expect(changed).toEqual(['1']) + expect(after.get('1')!.span).toEqual({ ...before.get('1')!.span, lineGap: before.get('1')!.span.lineGap + 2, gap: before.get('1')!.span.gap + 2 }) + for (const id of [...after.keys()].filter(id => id !== '0')) { + const was = placementOf(old).of(id)! + expect(placementOf(page).of(id), id).toEqual({ offset: was.offset + 2, line: was.line + 2, endLine: was.endLine + 2 }) + } + }) +}) diff --git a/vscode/webview-ui/src/codeReader/placement.ts b/vscode/webview-ui/src/codeReader/placement.ts new file mode 100644 index 0000000..4aa6370 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/placement.ts @@ -0,0 +1,54 @@ +import type { BlockView } from './types' + +/** 一块在文件里的位置:开头(第一行的行首)的下标、第一行、最后一行,行号 0 起 */ +export interface Place { + offset: number + line: number + endLine: number +} + +/** + * 一份文档里各块在文件里的位置(按块 id):从文件开头把各块的 span(见 BlockSpan)一块块累加出来,同扩展端 pageBlocks 的算法互逆。 + * 块里的下标都从块的开头算;页面上和文件里的位置打交道的——报给扩展端的(跳定义、悬停、单击、编辑、可见行), + * 拿扩展端发来的比的(中文名、各处出现、跳到哪一行哪个名字、差异的刻度)——都在这里换算。 + */ +export interface Placement { + /** 块在文件里的位置;不是这份文档里的块(删掉的块)没有 */ + of: (id: string) => Place | undefined +} + +/** 没有文档时:哪块都没有位置 */ +export const NO_PLACEMENT: Placement = { of: () => undefined } + +const placements = new WeakMap() + +/** blocks 这份块树里各块在文件里的位置(顶层第一块从文件开头算);同一份块树只算一次。 */ +export function placementOf(blocks: readonly BlockView[]): Placement { + let placement = placements.get(blocks) + if (!placement) { + const places = new Map() + const visit = (list: readonly BlockView[], from: { line: number, offset: number }): void => { + let cursor = from + for (const { id, span, children } of list) { + const start = { line: cursor.line + span.lineGap, offset: cursor.offset + span.gap } + places.set(id, { offset: start.offset, line: start.line, endLine: start.line + span.lines }) + visit(children, start) + cursor = { line: start.line + span.lines, offset: start.offset + span.length } + } + } + visit(blocks, { line: 0, offset: 0 }) + placement = { of: id => places.get(id) } + placements.set(blocks, placement) + } + return placement +} + +/** 记着块 id 的元素:卡片,和钉在顶上的卡片头;里面名字的下标从这块的开头算 */ +const OWNER = '.cr-block[data-id], .cr-sticky-line[data-id]' + +/** 页面上一个带下标的名字(SegmentText 画的 data-offset)在文件里的下标:它记的是从所在那块的开头算的,加上那块的开头。 */ +export function nameOffset(name: HTMLElement, placement: Placement): number | undefined { + const owner = name.closest(OWNER)?.dataset.id + const place = owner === undefined ? undefined : placement.of(owner) + return name.dataset.offset === undefined || !place ? undefined : place.offset + Number(name.dataset.offset) +} diff --git a/vscode/webview-ui/src/codeReader/positions.test.tsx b/vscode/webview-ui/src/codeReader/positions.test.tsx index ed2786d..6423da8 100644 --- a/vscode/webview-ui/src/codeReader/positions.test.tsx +++ b/vscode/webview-ui/src/codeReader/positions.test.tsx @@ -7,8 +7,8 @@ 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 { peekBlock } from '../../../src/codeReader/editor/definitionPeek' -import { documentMessage } from '../../../src/codeReader/editor/documentMessage' +import { pagePeekBlock, peekBlock } from '../../../src/codeReader/editor/definitionPeek' +import { documentMessage, pageDocument } from '../../../src/codeReader/editor/documentMessage' import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { CodeReaderRegistry } from '../../../src/codeReader/registry' import { runtime, WASM_DIR } from '../../../src/codeReader/testSupport' @@ -77,7 +77,7 @@ async function read(source: string, version: number): Promise { - return { ...await read(source, version), names: [] } as ExtensionToWebview + return { ...pageDocument(await read(source, version)), names: [] } as ExtensionToWebview } /** 扩展端发给页面的定义卡片:source 里 needle 开头那个名字所在的块,名字显示成 name */ @@ -85,7 +85,7 @@ async function peekOf(source: string, needle: string, name: string): Promise boolean): RevealPlan | undefined { + const page = placed(blocks) + return occurrencePlan(page, placementOf(page), occurrence, skip) +} + +/** 扩展端转出的块(位置是文件里的)照扩展端发给页面的样子排好,再找跳到哪 */ +function revealInSource(views: HostBlockView[], source: string, line: number, definition: number): RevealPlan | undefined { + const page = pageBlocks(views, source) + return revealPlan(page, placementOf(page), line, definition) } /** @@ -35,7 +58,7 @@ function factBlock(id: string, kind: BlockKind, startLine: number, properties: P * 4 return get_db(path) * 8 LIMIT = 3 */ -const BLOCKS: BlockView[] = [ +const BLOCKS: WrittenBlock[] = [ factBlock('0', 'import', 0, [ { label: '来源', value: [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)] }, { label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 34)]] }] }, @@ -57,151 +80,151 @@ describe('用键盘跳到高亮的一处出现:滚到哪块、展开哪些', ( const at = (start: number, end: number): Occurrence => ({ start, end, kind: 'read' }) it('在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { - expect(occurrencePlan(BLOCKS, at(102, 108), nowhere)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + expect(occurrenceIn(BLOCKS, at(102, 108), nowhere)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) }) it('在属性行展开的子行里(导入项、参数):连那一行一起展开;在属性行的值里不用展开', () => { - expect(occurrencePlan(BLOCKS, at(34, 40), nowhere)).toEqual({ id: '0', expand: ['0/p1'] }) - expect(occurrencePlan(BLOCKS, at(51, 55), nowhere)).toEqual({ id: '1', expand: ['1/p2'] }) - expect(occurrencePlan(BLOCKS, at(130, 135), nowhere)).toEqual({ id: '2', expand: [] }) + expect(occurrenceIn(BLOCKS, at(34, 40), nowhere)).toEqual({ id: '0', expand: ['0/p1'] }) + expect(occurrenceIn(BLOCKS, at(51, 55), nowhere)).toEqual({ id: '1', expand: ['1/p2'] }) + expect(occurrenceIn(BLOCKS, at(130, 135), nowhere)).toEqual({ id: '2', expand: [] }) }) it('和段重叠就算(点分模块名的一节、比名字长的段);页面上哪儿都没有时没有', () => { - expect(occurrencePlan(BLOCKS, at(18, 20), nowhere)).toEqual({ id: '0', expand: [] }) - expect(occurrencePlan(BLOCKS, at(62, 70), nowhere)).toBeUndefined() + expect(occurrenceIn(BLOCKS, at(18, 20), nowhere)).toEqual({ id: '0', expand: [] }) + expect(occurrenceIn(BLOCKS, at(62, 70), nowhere)).toBeUndefined() }) it('被筛掉的、正在编辑的、被盖住的块里的不算;只跳过那一块,它下面画出来的子块照常算', () => { const hidden = new Set(['1.0', '1.0.0']) - expect(occurrencePlan(BLOCKS, at(102, 108), id => hidden.has(id))).toBeUndefined() - expect(occurrencePlan(BLOCKS, at(51, 55), id => id === '1')).toBeUndefined() - expect(occurrencePlan(BLOCKS, at(102, 108), id => id === '1')).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + expect(occurrenceIn(BLOCKS, at(102, 108), id => hidden.has(id))).toBeUndefined() + expect(occurrenceIn(BLOCKS, at(51, 55), id => id === '1')).toBeUndefined() + expect(occurrenceIn(BLOCKS, at(102, 108), id => id === '1')).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) }) it('同一处在页面上显示几次:取页面上的第一处;Markdown 里的不算', () => { - const blocks: BlockView[] = [ + const blocks: WrittenBlock[] = [ factBlock('0', 'function', 0, [{ label: '说明', value: { markdown: '见 `get_db`' } }], [ textBlock('0.0', 'return', 1, [word('返回'), name('get_db', 30)]), ]), factBlock('1', 'class', 2, [{ label: '字段', value: [{ text: '1 个', role: 'hint' }], columns: ['字段'], items: [{ cells: [[name('get_db', 30)]] }] }]), ] - expect(occurrencePlan(blocks, at(30, 36), nowhere)).toEqual({ id: '0.0', expand: ['0'] }) - expect(occurrencePlan(blocks, at(30, 36), id => id === '0.0')).toEqual({ id: '1', expand: ['1/p0'] }) + expect(occurrenceIn(blocks, at(30, 36), nowhere)).toEqual({ id: '0.0', expand: ['0'] }) + expect(occurrenceIn(blocks, at(30, 36), id => id === '0.0')).toEqual({ id: '1', expand: ['1/p0'] }) }) }) describe('跳到定义:滚到哪块、展开哪些', () => { it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { - expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + expect(revealIn(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) }) it('名字在一行译文下面那张表里(跨几行写的调用的实参):表总是展开的,目标是那块,不用展开什么', () => { - const call: BlockView = { + const call: WrittenBlock = { ...textBlock('0.0', 'return', 1, [word('返回'), word('新建'), name('UserDetailResponse', 10)]), table: { columns: ['参数', '值'], items: [{ cells: [[name('id', 40)], [name('user', 43), { text: '.', role: 'plain' }, name('id', 48)]] }] }, } const blocks = [textBlock('0', 'if', 0, [word('如果'), name('user', 3)], [call])] - expect(revealPlan(blocks, 9, 48)).toEqual({ id: '0.0', expand: ['0'] }) - expect(occurrencePlan(blocks, { start: 40, end: 42, kind: 'read' }, () => false)).toEqual({ id: '0.0', expand: ['0'] }) + expect(revealIn(blocks, 9, 48)).toEqual({ id: '0.0', expand: ['0'] }) + expect(occurrenceIn(blocks, { start: 40, end: 42, kind: 'read' }, () => false)).toEqual({ id: '0.0', expand: ['0'] }) }) it('名字在值那一格里套着的实参表里(套了几层都算):一行译文下面的不用展开,变量初始值那一行下面的展开那一行', () => { const leaf = { columns: ['参数', '值'], items: [{ cells: [[name('mp_app_secret', 70)], [name('secret', 90)]] }] } const nested = { columns: ['参数', '值'], items: [{ cells: [[name('credentials', 50)], [word('新建'), name('CredentialPresence', 62)]], table: { columns: ['参数', '值'], items: [{ cells: [[name('inner', 66)], [word('新建')]], table: leaf }] } }] } - const blocks: BlockView[] = [ + const blocks: WrittenBlock[] = [ { ...textBlock('0', 'return', 0, [word('返回'), word('新建'), name('Diagnostic', 10)]), table: nested }, factBlock('1', 'variable', 5, [{ label: '初始值', value: [word('新建'), name('Diagnostic', 110)], columns: ['参数', '值'], items: nested.items }]), ] - expect(revealPlan(blocks, 9, 90)).toEqual({ id: '0', expand: [] }) - expect(occurrencePlan(blocks, { start: 70, end: 83, kind: 'read' }, id => id === '0')).toEqual({ id: '1', expand: ['1/p0'] }) + expect(revealIn(blocks, 9, 90)).toEqual({ id: '0', expand: [] }) + expect(occurrenceIn(blocks, { start: 70, end: 83, kind: 'read' }, id => id === '0')).toEqual({ id: '1', expand: ['1/p0'] }) }) it('名字在拆开的列表里:列表项下面的实参表总是展开的;实参表格子里拆开的列表总是展开的,它的项下面的子行要展开那一项', () => { const filter = (at: number): PropertyItem => ({ cells: [[word('第 1 项')], [word('新建'), name('Filter', at)]], table: { columns: ['参数', '值'], items: [{ cells: [[name('field', at + 10)], [name('user_id', at + 20)]] }] } }) const map = (at: number): PropertyItem => ({ cells: [[word('第 2 项')], [word('字典 1 项')]], items: [{ cells: [[name('key', at)], [name('value', at + 5)]], defines: { startIndex: at, endIndex: at + 3 } }] }) - const blocks: BlockView[] = [ + const blocks: WrittenBlock[] = [ factBlock('0', 'variable', 0, [{ label: '初始值', value: [word('列表 2 项')], items: [filter(10), map(40)] }]), { ...textBlock('1', 'statement', 5, [word('调用'), name('query', 100)]), table: { columns: ['参数', '值'], items: [{ cells: [[name('filters', 110)], [word('列表 2 项')]], items: [filter(120), map(160)] }] } }, factBlock('2', 'variable', 9, [{ label: '初始值', value: [word('调用'), name('query', 200)], columns: ['参数', '值'], items: [{ cells: [[name('filters', 210)], [word('列表 2 项')]], items: [filter(220), map(260)] }] }]), ] - expect(revealPlan(blocks, 0, 30)).toEqual({ id: '0', expand: ['0/p0'] }) - expect(revealPlan(blocks, 0, 40)).toEqual({ id: '0', row: '0/p0/1/0', expand: ['0/p0', '0/p0/1'] }) - expect(revealPlan(blocks, 5, 140)).toEqual({ id: '1', expand: [] }) - expect(revealPlan(blocks, 5, 165)).toEqual({ id: '1', expand: ['1/t/0/1'] }) - expect(revealPlan(blocks, 9, 240)).toEqual({ id: '2', expand: ['2/p0'] }) - expect(revealPlan(blocks, 9, 260)).toEqual({ id: '2', row: '2/p0/0/1/0', expand: ['2/p0', '2/p0/0/1'] }) - expect(occurrencePlan(blocks, { start: 165, end: 170, kind: 'read' }, () => false)).toEqual({ id: '1', expand: ['1/t/0/1'] }) - expect(occurrencePlan(blocks, { start: 140, end: 147, kind: 'read' }, () => false)).toEqual({ id: '1', expand: [] }) + expect(revealIn(blocks, 0, 30)).toEqual({ id: '0', expand: ['0/p0'] }) + expect(revealIn(blocks, 0, 40)).toEqual({ id: '0', row: '0/p0/1/0', expand: ['0/p0', '0/p0/1'] }) + expect(revealIn(blocks, 5, 140)).toEqual({ id: '1', expand: [] }) + expect(revealIn(blocks, 5, 165)).toEqual({ id: '1', expand: ['1/t/0/1'] }) + expect(revealIn(blocks, 9, 240)).toEqual({ id: '2', expand: ['2/p0'] }) + expect(revealIn(blocks, 9, 260)).toEqual({ id: '2', row: '2/p0/0/1/0', expand: ['2/p0', '2/p0/0/1'] }) + expect(occurrenceIn(blocks, { start: 165, end: 170, kind: 'read' }, () => false)).toEqual({ id: '1', expand: ['1/t/0/1'] }) + expect(occurrenceIn(blocks, { start: 140, end: 147, kind: 'read' }, () => false)).toEqual({ id: '1', expand: [] }) }) it('名字在一行译文下面拆开的各项里:各项总是展开的,讲这个定义的项(字典的键)只闪它;项下面的下一层要展开那一项,实参表总是展开的', () => { - const blocks: BlockView[] = [ + const blocks: WrittenBlock[] = [ { ...textBlock('0', 'return', 0, [word('返回'), word('列表 2 项')]), items: [ { cells: [[word('第 1 项')], [word('新建'), name('Filter', 10)]], table: { columns: ['参数', '值'], items: [{ cells: [[name('field', 20)], [name('user_id', 30)]] }] } }, { cells: [[word('第 2 项')], [word('字典 1 项')]], items: [{ cells: [[name('key', 40)], [name('value', 45)]], defines: { startIndex: 40, endIndex: 43 } }] }, ] }, { ...textBlock('1', 'return', 5, [word('返回'), word('对象 1 项')]), items: [{ cells: [[name('locked', 60)], [name('flag', 70)]], defines: { startIndex: 60, endIndex: 66 } }] }, ] - expect(revealPlan(blocks, 0, 10)).toEqual({ id: '0', expand: [] }) - expect(revealPlan(blocks, 0, 30)).toEqual({ id: '0', expand: [] }) - expect(revealPlan(blocks, 0, 40)).toEqual({ id: '0', row: '0/i/1/0', expand: ['0/i/1'] }) - expect(revealPlan(blocks, 0, 45)).toEqual({ id: '0', expand: ['0/i/1'] }) - expect(revealPlan(blocks, 5, 60)).toEqual({ id: '1', row: '1/i/0', expand: [] }) - expect(occurrencePlan(blocks, { start: 45, end: 50, kind: 'read' }, () => false)).toEqual({ id: '0', expand: ['0/i/1'] }) - expect(occurrencePlan(blocks, { start: 30, end: 37, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] }) + expect(revealIn(blocks, 0, 10)).toEqual({ id: '0', expand: [] }) + expect(revealIn(blocks, 0, 30)).toEqual({ id: '0', expand: [] }) + expect(revealIn(blocks, 0, 40)).toEqual({ id: '0', row: '0/i/1/0', expand: ['0/i/1'] }) + expect(revealIn(blocks, 0, 45)).toEqual({ id: '0', expand: ['0/i/1'] }) + expect(revealIn(blocks, 5, 60)).toEqual({ id: '1', row: '1/i/0', expand: [] }) + expect(occurrenceIn(blocks, { start: 45, end: 50, kind: 'read' }, () => false)).toEqual({ id: '0', expand: ['0/i/1'] }) + expect(occurrenceIn(blocks, { start: 30, end: 37, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] }) }) it('名字在框里(套了几层都算)也是显示着它', () => { const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] } - const blocks: BlockView[] = [ + const blocks: WrittenBlock[] = [ textBlock('0', 'return', 0, [word('返回'), { style: 'iteration', label: '列表', segments: [word('遍历'), name('grid', 20), word('取'), inner] }]), ] - expect(revealPlan(blocks, 5, 40)).toEqual({ id: '0', expand: [] }) - expect(revealPlan(blocks, 5, 20)).toEqual({ id: '0', expand: [] }) + expect(revealIn(blocks, 5, 40)).toEqual({ id: '0', expand: [] }) + expect(revealIn(blocks, 5, 20)).toEqual({ id: '0', expand: [] }) }) it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => { - const blocks: BlockView[] = [ - { id: '0', kind: 'comment', startLine: 0, endLine: 0, attributes: { kind: 'comment' }, concepts: [{ id: 'comment', label: '注释', icon: 'message-square-text' }], children: [], translation: { markdown: '见 `get_db`' } }, + const blocks: WrittenBlock[] = [ + { id: '0', anchor: '0', kind: 'comment', lines: [0, 0], attributes: { kind: 'comment' }, concepts: [{ id: 'comment', label: '注释', icon: 'message-square-text' }], children: [], translation: { markdown: '见 `get_db`' } }, factBlock('1', 'function', 1, [ { label: '说明', value: { markdown: '同 `get_db`' } }, { label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] }, ]), ] - expect(revealPlan(blocks, 0, 30)).toEqual({ id: '1', expand: ['1/p1'] }) + expect(revealIn(blocks, 0, 30)).toEqual({ id: '1', expand: ['1/p1'] }) }) it('翻译插件没标 defines、名字在属性行展开的子行里(参数):闪整块,连那一行一起展开', () => { - expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] }) - expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] }) + expect(revealIn(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] }) + expect(revealIn(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] }) }) it('名字在属性行的值里:不用展开属性行', () => { - expect(revealPlan(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] }) - expect(revealPlan(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] }) + expect(revealIn(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] }) + expect(revealIn(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] }) }) it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => { - expect(revealPlan(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] }) - expect(revealPlan(BLOCKS, 4, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) - expect(revealPlan(BLOCKS, 8, 999)).toEqual({ id: '2', expand: [] }) + expect(revealIn(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] }) + expect(revealIn(BLOCKS, 4, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + expect(revealIn(BLOCKS, 8, 999)).toEqual({ id: '2', expand: [] }) }) it('一行落在后一块,不选已经结束的前一块;都包住时取最小的那块', () => { - const blocks = [ - { ...textBlock('0', 'class', 3, [word('类')]), endLine: 4 }, - { ...textBlock('1', 'function', 1, [word('函数')]), endLine: 8 }, + const blocks: WrittenBlock[] = [ + { ...textBlock('0', 'class', 3, [word('类')]), lines: [3, 4] }, + { ...textBlock('1', 'function', 1, [word('函数')]), lines: [1, 8] }, ] - expect(revealPlan(blocks, 6, 999)).toEqual({ id: '1', expand: [] }) + expect(revealIn(blocks, 6, 999)).toEqual({ id: '1', expand: [] }) const inner = textBlock('0.0', 'return', 2, [word('返回')]) - const outer = { ...textBlock('0', 'function', 1, [word('函数')], [inner]), endLine: 8 } - expect(revealPlan([outer], 2, 999)).toEqual({ id: '0.0', expand: ['0'] }) - expect(revealPlan([outer], 6, 999)).toEqual({ id: '0', expand: [] }) + const outer: WrittenBlock = { ...textBlock('0', 'function', 1, [word('函数')], [inner]), lines: [1, 8] } + expect(revealIn([outer], 2, 999)).toEqual({ id: '0.0', expand: ['0'] }) + expect(revealIn([outer], 6, 999)).toEqual({ id: '0', expand: [] }) }) it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => { - const plan = revealPlan(BLOCKS, 4, 102) - const hidden = (folds: Record): string[] => plan?.expand.filter(id => collapsedIds(BLOCKS, folds).has(id)) ?? [] + const plan = revealIn(BLOCKS, 4, 102) + const hidden = (folds: Record): string[] => plan?.expand.filter(id => collapsedIds(placed(BLOCKS), folds).has(id)) ?? [] expect(hidden({})).toEqual(['1']) expect(hidden({ 1: false })).toEqual([]) expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0']) @@ -212,16 +235,16 @@ describe('跳到定义:滚到哪块、展开哪些', () => { const start = { ...factBlock('0.1', 'function', 4, [{ label: '名称', value: [name('start', 200)] }], [statement]), slot: { row: 3, item: 1 } } const create = { ...factBlock('0.0', 'function', 2, [{ label: '名称', value: [name('new', 100)] }]), slot: { row: 3, item: 0 } } const card = factBlock('0', 'class', 1, [{ label: '名称', value: [name('Server', 10)] }], [create, start]) - expect(revealPlan([card], 0, 100)).toEqual({ id: '0.0', expand: [propertyRowId('0', 3), '0.0'] }) - expect(revealPlan([card], 0, 200)).toEqual({ id: '0.1', expand: [propertyRowId('0', 3), '0.1'] }) - expect(revealPlan([card], 0, 300)).toEqual({ id: '0.1.0', expand: [propertyRowId('0', 3), '0.1', propertyRowId('0.1', 2), '0.1.0'] }) + expect(revealIn([card], 0, 100)).toEqual({ id: '0.0', expand: [propertyRowId('0', 3), '0.0'] }) + expect(revealIn([card], 0, 200)).toEqual({ id: '0.1', expand: [propertyRowId('0', 3), '0.1'] }) + expect(revealIn([card], 0, 300)).toEqual({ id: '0.1.0', expand: [propertyRowId('0', 3), '0.1', propertyRowId('0.1', 2), '0.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: [] }) - expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] }) - expect(revealPlan([], 0, 0)).toBeUndefined() + expect(revealIn(sameLine, 1, 999)).toEqual({ id: '0', expand: [] }) + expect(revealIn(sameLine, 0, 999)).toEqual({ id: '0', expand: [] }) + expect(revealIn([], 0, 0)).toBeUndefined() }) }) @@ -231,7 +254,7 @@ describe('跳到定义:闪哪一处(defines)', () => { * 0 def f(*args): f 在 4,*args 在 6..11 * 1 a = 1; b = 2 一条语句声明两个变量,a 在 20,b 在 27 */ - const blocks: BlockView[] = [ + const blocks: WrittenBlock[] = [ { ...factBlock('0', 'function', 0, [ { label: '名称', value: [name('f', 4)], defines: range(4, 5) }, @@ -246,25 +269,25 @@ describe('跳到定义:闪哪一处(defines)', () => { ] it('父块的大范围盖住定义下标时,停在后面那个显示着这个名字的函数上,不闪父块', () => { - const parent = { + const parent: WrittenBlock = { ...factBlock('0', 'class', 1, [{ label: '名称', value: [name('DeployActionError', 10)] }]), - endLine: 12, + lines: [1, 12], defines: range(0, 400), } const fn = factBlock('1', 'function', 14, [{ label: '名称', value: [name('fetchAppDeployments', 80)] }]) - expect(revealPlan([parent, fn], 14, 80)).toEqual({ id: '1', expand: [] }) + expect(revealIn([parent, fn], 14, 80)).toEqual({ id: '1', expand: [] }) }) it('定义是块自己的名字:闪整块,属性行也标了它也一样', () => { - expect(revealPlan(blocks, 0, 4)).toEqual({ id: '0', expand: [] }) + expect(revealIn(blocks, 0, 4)).toEqual({ id: '0', expand: [] }) }) it('defines 是一段范围:定义落在中间也算(*args 的定义在 args 上)', () => { - expect(revealPlan(blocks, 0, 7)).toEqual({ id: '0', row: '0/p1/0', expand: ['0/p1'] }) + expect(revealIn(blocks, 0, 7)).toEqual({ id: '0', row: '0/p1/0', expand: ['0/p1'] }) }) it('一条语句声明几个变量:只闪那个变量的名称行', () => { - expect(revealPlan(blocks, 1, 27)).toEqual({ id: '1', row: '1/p1', expand: [] }) + expect(revealIn(blocks, 1, 27)).toEqual({ id: '1', row: '1/p1', expand: [] }) }) }) @@ -293,7 +316,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { return undefined } - function flatten(list: BlockView[]): BlockView[] { + function flatten(list: HostBlockView[]): HostBlockView[] { return list.flatMap(view => [view, ...flatten(view.children)]) } @@ -302,7 +325,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { } /** 闪的那一处:整块写成它的第一行源码,属性行写成「属性名: 值」,子行各列用 | 隔开。 */ - function flashed(views: BlockView[], plan: RevealPlan | undefined, source: string): string { + function flashed(views: HostBlockView[], plan: RevealPlan | undefined, source: string): string { const block = flatten(views).find(view => view.id === plan?.id) if (!block || !plan) return '' @@ -323,8 +346,8 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { async function jump(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, needle: string): Promise<{ plan: RevealPlan | undefined, flashed: string }> { const offset = source.indexOf(needle) expect(offset, needle).toBeGreaterThanOrEqual(0) - const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) - const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) + const views: HostBlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) + const plan = revealInSource(views, source, source.slice(0, offset).split('\n').length - 1, offset) return { plan, flashed: flashed(views, plan, source) } } @@ -412,7 +435,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { expect(await at('load')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'load(id: T) {' }) expect(await at('id: T')).toEqual({ plan: { id: '0.0', row: '0.0/p3/0', expand: ['0', '0.0/p3'] }, flashed: 'id | T | ' }) const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator)) - expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0']) + expect([...collapsedIds(pageBlocks(views, source), {})].sort()).toEqual(['0', '0.0']) const pySource = ['class NotFound(AppResponse):', ' """找不到。"""', ' def run(self):', ' pass'].join('\n') const atPy = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, pySource, needle) @@ -439,7 +462,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { expect(await atTs('status')).toEqual({ plan: { id: '0', row: '0/p3/0', expand: ['0/p3'] }, flashed: 'status | 500' }) expect(await atTs('code:')).toEqual({ plan: { id: '0', row: '0/p4/0', expand: ['0/p4'] }, flashed: 'code | 字符串 | ' }) const views = await withBlocks(ts, 'typescript', tsSource, blocks => toBlockViews(blocks, { source: tsSource, language: ts, markers: [] }, typescriptZhTranslator)) - expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0']) + expect([...collapsedIds(pageBlocks(views, tsSource), {})].sort()).toEqual(['0', '0.0']) }) it('库规则认领的块:类名闪整块,字段名只闪字段表里它那一行(先展开「字段」那一行);留下的方法照常,卡片默认折叠', async () => { @@ -457,13 +480,13 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} })) const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => { const offset = source.indexOf(needle) - const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) + const plan = revealInSource(views, source, source.slice(0, offset).split('\n').length - 1, offset) return { plan, flashed: flashed(views, plan, source) } } expect(at('User')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'class User(Base):' }) expect(at('name:')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'name | 字符串 | | 名字' }) expect(at('greet')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'def greet(self):' }) - expect([...collapsedIds(views, {})].sort()).toEqual(['1', '1.0']) + expect([...collapsedIds(pageBlocks(views, source), {})].sort()).toEqual(['1', '1.0']) }) it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => { diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index afc5b3b..f6196f0 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -1,3 +1,4 @@ +import type { Placement } from './placement' import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types' import { blockPath } from './filter' import { showsOccurrence, sourceOf } from './occurrences' @@ -50,7 +51,7 @@ function inlineSpan(pieces: Inline[], offset: number): number | undefined { return inner } else { - const range = sourceOf(piece) + const range = sourceOf(piece, 0) if (range && range.start <= offset && offset < range.end) return range.end - range.start } @@ -103,7 +104,7 @@ function definedItem(items: Iterable, offset: number): Spot | undefi } /** - * 块里哪儿是 offset 处的定义,从具体到笼统: + * 块里哪儿是 offset 处(块里的下标,从块的开头算)的定义,从具体到笼统: * ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块; * ② 属性表、一行译文下面的子行里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它, * 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开; @@ -163,20 +164,23 @@ function showingItem(items: Iterable, test: (cell: PreviewText) => b /** * 跳到定义:目标见 locate。几块的源码范围都盖住这个下标时,留盖住它的那段最短的块—— * 父块的大范围盖住后面那个函数的名字时,停在函数上,不闪父块。 - * 页面上找不到这处定义,就取包住 line 的块(startLine <= line <= endLine);几块都包住时取行数最少的,一样少时取先遇到的外层。 + * 页面上找不到这处定义,就取包住 line 的块(块在文件里的首尾行之间);几块都包住时取行数最少的,一样少时取先遇到的外层。 * 没有任何块包住这一行(它在所有块之前,或落在块与块的空档)时取第一块。没有块时没有目标。 - * 目标的祖先块都要展开。 + * 目标的祖先块都要展开。line、definition 是文件里的,各块里的位置按 placement 换算。 */ -export function revealPlan(blocks: BlockView[], line: number, definition: number): RevealPlan | undefined { +export function revealPlan(blocks: BlockView[], placement: Placement, line: number, definition: number): RevealPlan | undefined { let best: { plan: RevealPlan, span: number } | undefined let byLine: Placed | undefined let byLineSpan = Number.POSITIVE_INFINITY for (const item of placed(blocks)) { - const spot = locate(item.block, definition) + const place = placement.of(item.block.id) + if (!place) + continue + const spot = locate(item.block, definition - place.offset) if (spot && (best === undefined || spot.span < best.span)) best = { span: spot.span, plan: { id: item.block.id, ...(spot.row !== undefined && { row: spot.row }), expand: [...item.ancestors, ...spot.expand] } } - if (item.block.startLine <= line && line <= item.block.endLine) { - const span = item.block.endLine - item.block.startLine + if (place.line <= line && line <= place.endLine) { + const span = place.endLine - place.line if (span < byLineSpan) { byLine = item byLineSpan = span @@ -216,9 +220,10 @@ function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): st * 用键盘跳到高亮着的名字的一处出现:页面上按先后第一处显示着它(有段和它重叠)的块,以及要展开的块、属性行和子行。 * skip 的块(被筛掉的、正在编辑的、被正在编辑的祖先盖住的)里的不算;Markdown 说明里的名字不带下标,也不算。页面上哪儿都没有时是 undefined。 */ -export function occurrencePlan(blocks: BlockView[], occurrence: Occurrence, skip: (id: string) => boolean): RevealPlan | undefined { - const test = (text: PreviewText): boolean => !('markdown' in text) && showsOccurrence(text, occurrence) +export function occurrencePlan(blocks: BlockView[], placement: Placement, occurrence: Occurrence, skip: (id: string) => boolean): RevealPlan | undefined { for (const { block, ancestors } of placed(blocks)) { + const base = placement.of(block.id)?.offset + const test = (text: PreviewText): boolean => base !== undefined && !('markdown' in text) && showsOccurrence(text, base, occurrence) const expand = skip(block.id) ? undefined : showingSpot(block, test) if (expand) return { id: block.id, expand: [...ancestors, ...expand] } diff --git a/vscode/webview-ui/src/codeReader/testBlocks.ts b/vscode/webview-ui/src/codeReader/testBlocks.ts new file mode 100644 index 0000000..2b96914 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/testBlocks.ts @@ -0,0 +1,20 @@ +import type { BlockSpan, BlockView } from './types' + +/** + * 测试里手写的块:照文件里的位置写,下标就是文件里的(各块的开头都在下标 0),lines 是块在文件里的 [首行, 尾行](不写是接着前一块的尾行)。 + */ +export type WrittenBlock = Omit & { lines?: [number, number], children: WrittenBlock[] } + +/** 下标、行号都不挪:块里的位置就是文件里的 */ +export const NO_SPAN: BlockSpan = { lineGap: 0, gap: 0, lines: 0, length: 0 } + +/** 手写的块排成扩展端发来的样子:span 按各块的首尾行累加(见 placement.ts),各块的开头都在下标 0,块里的下标就是文件里的。 */ +export function placed(blocks: readonly WrittenBlock[], from = 0): BlockView[] { + let cursor = from + return blocks.map(({ lines, children, ...rest }) => { + const [start, end] = lines ?? [cursor, cursor] + const span: BlockSpan = { lineGap: start - cursor, gap: 0, lines: end - start, length: 0 } + cursor = end + return { ...rest, span, children: placed(children, start) } as BlockView + }) +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 49ac19f..ebb7cce 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -126,31 +126,41 @@ export interface BlockConcept { /** 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个。 */ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName } -interface BlockViewBase { +/** + * 块在源码里占的地方,按相对的量记,前面的块变长变短、挪了位置它都不跟着变: + * 离前一个兄弟块的结尾(第一个子块从父块的开头算,顶层第一块从文件开头算)隔几行(lineGap)、几个字符(gap), + * 自己占几行(lines,首尾两行的行号差)、几个字符(length,第一行行首到最后一行行尾)。块的开头是它第一行的行首。 + * 在文件里的位置由 placement.ts 从文件开头一块块累加出来。 + */ +export interface BlockSpan { + lineGap: number + gap: number + lines: number + length: number +} + +/** 一张卡片画的内容:块里的下标(名字的 offset、refers、defines)都从块的开头算,换成文件里的下标见 placement.ts */ +interface CardViewBase { /** 在树里的位置,例如 '6.1.0' */ id: string /** 块的位置记号:问答挂在它上面(QaThreadView.anchor),文件别处改了照样对得上;webview 只拿它比相等,不拆开看 */ anchor: string /** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table)。筛选用它;图标不看它,看 concepts */ kind: string - /** 编辑范围的起止行,0 起:块的完整源码,头部连同各子孙块、结尾(块编辑时子孙都不画,见 coveredByEditing);编辑框里的源码按它从文档的源码里取(见 blockSource) */ - startLine: number - endLine: number - /** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */ + /** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在块里的范围:跳到的定义就是它时闪整块 */ defines?: SourceRange /** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */ attributes: Record /** 这块认出来的概念,从左到右。webview 按图标名画,不自己用种类查 */ concepts: BlockConcept[] - /** 「重写注释」写到哪(扩展端用,这边只看有没有、是换掉还是新写);没有就不显示按钮 */ - commentTarget?: CommentTarget + /** 「重写注释」是换掉已有的注释,还是新写一段(扩展端按块的记号找写到哪);没有就不显示按钮 */ + commentTarget?: 'replace' | 'insert' /** 库规则的卡片才有:卡片边框的颜色,筛选栏里这个种类的名字。和类块一样默认收起子块 */ badge?: KindBadge /** 库规则挂在父卡片子行上的子块才有:展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里;见 BlockSlot */ slot?: BlockSlot /** 能看调用树的块才有(有名字的函数、类,讲 HTTP 接口的卡片):有它才摆调用树按钮 */ callTree?: CallTreeTarget - children: BlockView[] } /** 一块能看调用树:接口卡片带上它讲的接口(扩展端查前端调用者用,这边只看有没有 callTree)。 */ @@ -167,20 +177,21 @@ export interface BlockSlot { item: number } -/** - * 「重写注释」写到哪,行号 0 起:replace 换掉 startLine..endLine 这几行(块前面的普通注释;文件开头的说明卡片是它自己), - * insert 是块还没有注释,新写一段插在 line 这一行上面 - */ -export type CommentTarget - = | { kind: 'replace', startLine: number, endLine: number } - | { kind: 'insert', line: number } - /** * 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文; * 一行译文下面依次是:跨几行写的字面量拆开的各项(items),跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文), * 几行属性(rows:「注释」一行) */ -export type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) +export type CardView = CardViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) + +/** + * 一块:卡片的内容,在源码里占的地方(span,相对的;块编辑时编辑框里是它的整行源码,子孙都不画,见 coveredByEditing),子块。 + * 内容没变、只是挪了位置的块,扩展端发来的一模一样 + */ +export type BlockView = CardView & { + span: BlockSpan + children: BlockView[] +} export interface TranslatorOption { id: string, label: string } @@ -232,7 +243,8 @@ export interface Occurrence { /** * 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径)、定义处那一行(0 起), - * 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的,不是页面上这份文档的) + * 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片;它自己当那个文件的顶层第一块,span 从文件开头算), + * 显示成中文名的名字(下标是那个文件里的,不是页面上这份文档的) */ export interface DefinitionPeek { path: string @@ -443,7 +455,7 @@ export interface ChangedBlock { export interface RemovedView { parent: string after?: string - block: BlockView + block: CardView line: number kindName: string } diff --git a/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts index d0a83cf..3fd3157 100644 --- a/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts +++ b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts @@ -1,6 +1,8 @@ +import type { Placement } from './placement' import { useEffect, useState } from 'react' import { DefinitionLinks } from './definitionLinks' import { documentNameAt } from './definitionPeek' +import { nameOffset } from './placement' import { onMessage, postMessage } from './vscode' /** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */ @@ -10,17 +12,17 @@ function modifierHeld(event: MouseEvent | KeyboardEvent): boolean { return IS_MAC ? event.metaKey : event.ctrlKey } -function offsetOf(el: HTMLElement | null): number | undefined { - return el?.dataset.offset === undefined ? undefined : Number(el.dataset.offset) -} - -/** 指针下的名字、修饰键按没按;把链接样式(data-link)挂到指着的那个名字上。 */ +/** 指针下的名字、修饰键按没按;把链接样式(data-link)挂到指着的那个名字上。名字在文件里的下标按 placement 换算。 */ class Pointer { private hovered: HTMLElement | null = null private held = false private linked: HTMLElement | null = null - constructor(private readonly links: DefinitionLinks) {} + constructor(private readonly links: DefinitionLinks, private readonly placement: () => Placement) {} + + offsetOf(el: HTMLElement | null): number | undefined { + return el ? nameOffset(el, this.placement()) : undefined + } move(target: EventTarget | null, held: boolean): void { this.hovered = documentNameAt(target) @@ -35,7 +37,7 @@ class Pointer { /** 把按住修饰键时指着的名字告诉 links,再按它的回答挂上或摘掉链接样式 */ sync(): void { - const offset = offsetOf(this.hovered) + const offset = this.offsetOf(this.hovered) this.links.point(this.held ? offset : undefined) const link = offset !== undefined && offset === this.links.linked ? this.hovered : null if (link === this.linked) @@ -48,11 +50,11 @@ class Pointer { /** * 和 VS Code 编辑器里一样:按住 ⌘ / Ctrl、鼠标停在查得到定义的名字上时,名字显示成链接,点一下跳到定义。 - * version 是当前显示的文档版本,名字的下标属于它。 + * version 是当前显示的文档版本,名字的下标属于它;placement 是它各块在文件里的位置,名字的下标按它换成文件里的。 */ -export function useDefinitionLinks(version: number | undefined): void { +export function useDefinitionLinks(version: number | undefined, placement: () => Placement): void { const [links] = useState(() => new DefinitionLinks(postMessage)) - const [pointer] = useState(() => new Pointer(links)) + const [pointer] = useState(() => new Pointer(links, placement)) useEffect(() => { links.setVersion(version) @@ -69,7 +71,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(documentNameAt(event.target)) : undefined)) + if (!links.click(modifierHeld(event) ? pointer.offsetOf(documentNameAt(event.target)) : undefined)) return event.preventDefault() event.stopPropagation() diff --git a/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts b/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts index c5b254a..21be7fb 100644 --- a/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts +++ b/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts @@ -1,11 +1,13 @@ import type { PeekTarget, ShownPeek } from './definitionPeek' +import type { Placement } from './placement' import { useEffect, useState } from 'react' import { DefinitionPeeks, documentNameAt, inPeek, linkPeekSource } from './definitionPeek' +import { nameOffset } from './placement' import { commentLinkOf } from './repoLink' import { onMessage, postMessage } from './vscode' -/** 指针下能弹定义卡片的一处:注释里指到符号的链接,或页面上这份文档带下标的名字;浮窗里的不算。 */ -function peekTargetAt(target: EventTarget | null): PeekTarget | null { +/** 指针下能弹定义卡片的一处:注释里指到符号的链接,或页面上这份文档带下标的名字(下标按 placement 换成文件里的);浮窗里的不算。 */ +function peekTargetAt(target: EventTarget | null, placement: Placement): PeekTarget | null { if (!(target instanceof Element) || inPeek(target)) return null const link = target.closest('.cr-markdown a') @@ -14,7 +16,8 @@ function peekTargetAt(target: EventTarget | null): PeekTarget | nul return source ? { anchor: link, source } : null } const name = documentNameAt(target) - return name?.dataset.offset === undefined ? null : { anchor: name, source: { offset: Number(name.dataset.offset) } } + const offset = name ? nameOffset(name, placement) : undefined + return !name || offset === undefined ? null : { anchor: name, source: { offset } } } function isInPeek(target: EventTarget | null): boolean { @@ -24,9 +27,9 @@ function isInPeek(target: EventTarget | null): boolean { /** * 和 VS Code 编辑器里的悬停一样:鼠标停在带下标的名字、注释里指到符号的链接上,过一会儿(editor.hover.delay)弹出定义所在那块的卡片, * 移开名字和浮窗过一会儿(editor.hover.hidingDelay)收起,Esc、滚动、按下鼠标立刻收(见 DefinitionPeeks)。 - * version 是当前显示的文档版本,名字的下标属于它。返回弹着的卡片和点标题时的跳转。 + * version 是当前显示的文档版本,名字的下标属于它;placement 是它各块在文件里的位置。返回弹着的卡片和点标题时的跳转。 */ -export function useDefinitionPeek(version: number | undefined): { shown: ShownPeek | undefined, jump: () => void } { +export function useDefinitionPeek(version: number | undefined, placement: () => Placement): { shown: ShownPeek | undefined, jump: () => void } { const [shown, setShown] = useState | undefined>() const [peeks] = useState(() => new DefinitionPeeks(postMessage, setShown)) @@ -39,7 +42,7 @@ export function useDefinitionPeek(version: number | undefined): { shown: ShownPe if (isInPeek(event.target)) peeks.enterPopup() else - peeks.point(peekTargetAt(event.target)) + peeks.point(peekTargetAt(event.target, placement())) } const onOut = (event: PointerEvent): void => { if (event.relatedTarget === null) @@ -81,7 +84,7 @@ export function useDefinitionPeek(version: number | undefined): { shown: ShownPe window.removeEventListener('scroll', onScroll, true) peeks.dispose() } - }, [peeks]) + }, [peeks, placement]) return { shown, jump: () => peeks.jump() } } diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index 18fa9aa..26752b0 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -1,10 +1,12 @@ import type { Click, Highlight, ScrollPosition } from './occurrences' +import type { Placement } from './placement' import type { TextMarks } from './SegmentText' import type { Store } from './store' import type { BlockView, Occurrence } from './types' import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek' import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences' +import { nameOffset, NO_PLACEMENT } from './placement' import { occurrencePlan } from './reveal' import { useStore } from './store' import { showInViewport } from './useReveal' @@ -19,6 +21,8 @@ const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset /** 用键盘跳到一处出现时要用的页面状态。 */ export interface OccurrencePage { blocks: BlockView[] + /** 各块在文件里的位置:点中的名字、各处出现按它和文件里的下标对上 */ + placement: Placement /** 这块里的出现跳不到:被筛掉的、正在编辑的、被正在编辑的祖先盖住的 */ skip: (id: string) => boolean /** 眼下折叠着的块、属性行和子行 */ @@ -27,16 +31,18 @@ export interface OccurrencePage { expand: (ids: string[]) => void } -function clickOf(event: MouseEvent): Click { +/** 一次点击的情况;点中的名字在文件里的下标按 placement 换算 */ +function clickOf(event: MouseEvent, placement: Placement): Click { const target = event.target instanceof Element ? event.target : null const name = documentNameAt(target) + const offset = name ? nameOffset(name, placement) : undefined const selection = window.getSelection() return { modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey, count: event.detail, selecting: selection !== null && !selection.isCollapsed, // 起了中文名的名字写的是中文名(定义处后面还跟着原名),筛选栏上写中文名 - name: name?.dataset.offset === undefined ? undefined : { offset: Number(name.dataset.offset), text: name.dataset.name ?? name.textContent ?? '' }, + name: !name || offset === undefined ? undefined : { offset, text: name.dataset.name ?? name.textContent ?? '' }, control: target?.closest(CONTROLS) != null, } } @@ -122,7 +128,7 @@ export function useOccurrences(version: number | undefined, translatorId: string keyScroll.forget() } const current = latestPage.current - const planOf = (occurrence: Occurrence): ReturnType => current && occurrencePlan(current.blocks, occurrence, current.skip) + const planOf = (occurrence: Occurrence): ReturnType => current && occurrencePlan(current.blocks, current.placement, occurrence, current.skip) const target = current && occurrences.move(step, occurrence => planOf(occurrence) !== undefined) const plan = target && planOf(target) if (!current || !plan) { @@ -135,7 +141,7 @@ export function useOccurrences(version: number | undefined, translatorId: string setMoves(count => count + 1) } // 捕获阶段:赶在块里的按钮处理点击、折叠箭头换图标之前读出点在哪 - const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event))) + const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event, latestPage.current?.placement ?? NO_PLACEMENT))) moveFromButton.current = step => move(step, false) // 任何键都照常交给 VS Code。能跳时,默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)取消浏览器滚到底 / 滚到顶,但不停冒泡,命令还是收得到 const onKeyDown = (event: KeyboardEvent): void => { diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 2e6d107..96097b5 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -3,6 +3,7 @@ import type { DocumentMessage, Reveal } from './types' import { useLayoutEffect } from 'react' import { blockPath } from './filter' import { pageLayout } from './pageLayout' +import { placementOf } from './placement' import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal' import { stickyHeight } from './useStickyScroll' @@ -44,7 +45,7 @@ export function showInViewport(el: HTMLElement, beforeScroll?: (position: Scroll window.scrollTo(position.x, position.y) } -/** 这块自己显示着的、下标就是定义处的那个名字。子块里的同名下标不算,免得闪到旁边那张卡片。 */ +/** 这块自己显示着的、下标(块里的)就是定义处的那个名字。子块里的同名下标不算,免得闪到旁边那张卡片。 */ function nameAt(block: HTMLElement, offset: number): HTMLElement | null { for (const el of block.querySelectorAll('[data-offset]')) { if (Number(el.dataset.offset) !== offset) @@ -83,7 +84,8 @@ export function useReveal( done() return } - const found = revealPlan(doc.blocks, reveal.line, reveal.definition) + const placement = placementOf(doc.blocks) + const found = revealPlan(doc.blocks, placement, reveal.line, reveal.definition) const plan = found && uncoveredPlan(found, covered) const path = plan ? blockPath(plan.id) : [] if (path.some(id => filtered.has(id))) { @@ -98,7 +100,8 @@ export function useReveal( // 块正在编辑时页面上没有属性表,就闪整块 const block = plan ? document.querySelector(`.cr-block[data-id="${plan.id}"]`) : null const row = plan?.row === undefined ? null : document.querySelector(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`) - const name = row || !block ? null : nameAt(block, reveal.definition) + const base = plan && placement.of(plan.id)?.offset + const name = row || !block || base === undefined ? null : nameAt(block, reveal.definition - base) const el = row ?? name ?? block if (el) { showInViewport(el)