⚡ perf(vscode): 代码阅读预览发给页面的块只记相对位置:块在源码里占的地方记成离前一块多远、自己多长,块里名字的下标从块的开头算,页面从文件开头累加回文件里的位置、和扩展端打交道的地方都在一处换算;靠前改了代码,后面内容没变的块发过去一模一样,扩展端自己的块和发给页面的块分成两种类型

This commit is contained in:
cruldra committed 2026-10-13 16:32:18 +11:00
1 parent 3a2815c19c
commit ad5f559e92
43 files changed
+921 -365

No files matched your search

+26 -3
View File
@@ -1,12 +1,15 @@
import type { FileAttribute } from '../attributes' import type { FileAttribute } from '../attributes'
import type { BlockView } from '../blockView' import type { BlockView } from '../blockView'
import type { PageCard } from '../editor/messages'
import type { ChangedBlock, DiffStats, RemovedBlock } from './blockDiff' import type { ChangedBlock, DiffStats, RemovedBlock } from './blockDiff'
import type { DiffBase } from './diffBase' import type { DiffBase } from './diffBase'
import { pageCard, pageMarks, sourceLines } from '../editor/pageBlocks'
import { splitLines } from '../editText' import { splitLines } from '../editText'
import { diffBlockTrees } from './blockDiff' import { diffBlockTrees } from './blockDiff'
/** 删掉的一块(见 RemovedBlock),带上种类的名字:旧版本里才有的种类,新文件的筛选属性里查不到它叫什么。 */ /** 删掉的一块(见 RemovedBlock)发给页面的样子:旧版本里它自己的内容(见 pageCard),带上种类的名字(旧版本里才有的种类,新文件的筛选属性里查不到它叫什么)。 */
export interface RemovedView extends RemovedBlock { export interface RemovedView extends Omit<RemovedBlock, 'block'> {
block: PageCard
kindName: string kindName: string
} }
@@ -49,6 +52,20 @@ function kindNames(attributes: readonly FileAttribute[]): Map<string, string> {
return new Map(attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])) return new Map(attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name]))
} }
/** 各块的开头(第一行的行首)在文件里的下标,按块的记号:卡片里要标的改动发给页面时从块的开头算(见 pageMarks) */
function blockStarts({ blocks, source }: SentDocument): Map<string, number> {
const lines = sourceLines(source)
const starts = new Map<string, number>()
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)。 */ /** 按基准比出来的差异:基准里没有这个文件时整份是新文件,否则两棵块树比(见 diffBlockTrees)。 */
export function documentDiff(base: DiffBase, sent: SentDocument, old: { source: string, document: OldDocument } | null): DocumentDiff { 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 } 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 } return { ...common, newFile: base.missing, blocks: {}, removed: [], stats: NO_CHANGES }
const { blocks, removed, stats } = diffBlockTrees({ blocks: old.document.blocks, source: old.source }, sent) const { blocks, removed, stats } = diffBlockTrees({ blocks: old.document.blocks, source: old.source }, sent)
const kinds = kindNames(old.document.attributes) 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,
}
} }
/** /**
@@ -1,7 +1,9 @@
import type { BlockView } from '../blockView' import type { BlockView } from '../blockView'
import type { DisplayName } from '../contract' import type { DisplayName } from '../contract'
import type { ShownName } from '../nameResolution' import type { ShownName } from '../nameResolution'
import type { PageBlock } from './messages'
import { renderedNames } from '../nameResolution' import { renderedNames } from '../nameResolution'
import { pageBlocks } from './pageBlocks'
function covers(block: BlockView, offset: number): boolean { function covers(block: BlockView, offset: number): boolean {
return block.defines !== undefined && block.defines.startIndex <= offset && offset < block.defines.endIndex 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 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[] { export function namesOnCard(block: BlockView, names: readonly ShownName[]): ShownName[] {
const rendered = renderedNames([block]) const rendered = renderedNames([block])
@@ -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 { DisplayName, LocalSymbol, NameBinding, RuleContext, SourceDocument } from '../contract'
import type { CodeReaderRegistry } from '../registry' import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime' import type { TreeSitterRuntime } from '../runtime'
@@ -9,6 +9,7 @@ import { readBlockViews } from '../blockView'
import { commandRuleLookup } from '../commandRules' import { commandRuleLookup } from '../commandRules'
import { markEntryBlocks } from '../entries/entryMarks' import { markEntryBlocks } from '../entries/entryMarks'
import { ruleAttributeNames } from '../libraryRules' import { ruleAttributeNames } from '../libraryRules'
import { pageBlocks } from './pageBlocks'
/** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */ /** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */
export interface DocumentSnapshot { export interface DocumentSnapshot {
@@ -21,13 +22,21 @@ export interface DocumentSnapshot {
entryNames?: readonly string[] entryNames?: readonly string[]
} }
/** 扩展端这边的一份文档:同发给预览的 document 消息,块是扩展端的 BlockView(位置都是文件里的);发出去前换成页面收的样子(pageDocument)。 */
export type DocumentView = Omit<DocumentMessage, 'blocks'> & { blocks: BlockView[] }
/** 发给预览的 document 消息:块换成页面收的样子(见 pageBlocks)。 */
export function pageDocument({ blocks, ...rest }: DocumentView): DocumentMessage {
return { ...rest, blocks: pageBlocks(blocks, rest.source) }
}
/** /**
* 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)、 * 读出来的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)、
* 各条导入引进来的名字(注释里不带路径的引用按它找导入的定义)、各块自己的名字和种类(调用的定义就在这份文档里时认种类用,见 NamedKind)、 * 各条导入引进来的名字(注释里不带路径的引用按它找导入的定义)、各块自己的名字和种类(调用的定义就在这份文档里时认种类用,见 NamedKind)、
* 各个名字的绑定(找中文名时同一个绑定问一处,见 NameBinding)。 * 各个名字的绑定(找中文名时同一个绑定问一处,见 NameBinding)。
*/ */
export interface DocumentRead { export interface DocumentRead {
message: DocumentMessage message: DocumentView
displayNames: DisplayName[] displayNames: DisplayName[]
symbols: LocalSymbol[] symbols: LocalSymbol[]
imports: ImportedName[] imports: ImportedName[]
@@ -36,7 +45,7 @@ export interface DocumentRead {
} }
/** /**
* 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下; * 当前文档 → 扩展端这边的一份文档(DocumentView,发出去前见 pageDocument);语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下;
* 规则查名字的定义时问 lookup(RuleContext.lookup),翻译插件问调用的名字定义成哪种块时问 calleeKind(SourceDocument.calleeKind)。 * 规则查名字的定义时问 lookup(RuleContext.lookup),翻译插件问调用的名字定义成哪种块时问 calleeKind(SourceDocument.calleeKind)。
* 筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。 * 筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。
* 入口文件里名字对上的块,概念列表的第一个换成入口(markEntryBlocks)。 * 入口文件里名字对上的块,概念列表的第一个换成入口(markEntryBlocks)。
@@ -52,7 +61,7 @@ export async function documentMessage(
const { languageId, version } = document const { languageId, version } = document
const source = document.getText() const source = document.getText()
const translator = registry.selectedTranslator(languageId) const translator = registry.selectedTranslator(languageId)
const message: DocumentMessage = { const message: DocumentView = {
type: 'document', type: 'document',
version, version,
languageId, languageId,
+43 -4
View File
@@ -4,9 +4,10 @@
import type { ClaudeImage } from '../../cc/spawnClaude' import type { ClaudeImage } from '../../cc/spawnClaude'
import type { FileAttribute } from '../attributes' 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 { ContractMiss } from '../callTree/endpoint'
import type { CallTree } from '../callTree/model' 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 { OpenedFrom } from '../diff/diffBase'
import type { DocumentDiff } from '../diff/previewDiff' import type { DocumentDiff } from '../diff/previewDiff'
import type { ShownName } from '../nameResolution' import type { ShownName } from '../nameResolution'
@@ -48,14 +49,52 @@ export interface Occurrence {
kind: 'text' | 'read' | 'write' 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 起), * 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径,同挑选框)、定义处那一行(0 起),
* 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的)。 * 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片;它自己当那个文件的顶层第一块,span 从文件开头算),
* 显示成中文名的名字(下标是那个文件里的)。
*/ */
export interface DefinitionPeek { export interface DefinitionPeek {
path: string path: string
line: number line: number
block: BlockView block: PageBlock
names: ShownName[] names: ShownName[]
} }
@@ -64,7 +103,7 @@ export interface DocumentMessage {
/** TextDocument.version:webview 靠它判断是不是新一份文档 */ /** TextDocument.version:webview 靠它判断是不是新一份文档 */
version: number version: number
languageId: string languageId: string
blocks: BlockView[] blocks: PageBlock[]
/** 整份源码和它的着色,只这一份:编辑框里一块的源码、着色按它的编辑范围取;解析失败、语言不支持时是空的(没有块) */ /** 整份源码和它的着色,只这一份:编辑框里一块的源码、着色按它的编辑范围取;解析失败、语言不支持时是空的(没有块) */
source: string source: string
colors: SourceColors colors: SourceColors
+167
View File
@@ -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<number, TextDiff>, base: number): Record<number, TextDiff> {
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) }),
}
}
@@ -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('./pathPreview', () => ({ PATH_PREVIEW_SCHEME: 'code-reader-path', topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }) }))
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() })) vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
/** 排视图数据不真的解析:一份没有块的文档 */ /** 排视图数据不真的解析:一份没有块的文档 */
vi.mock('./documentMessage', () => ({ vi.mock('./documentMessage', async importOriginal => ({
...await importOriginal<typeof import('./documentMessage')>(),
documentMessage: vi.fn(async (input: { languageId: string, version: number }) => ({ 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: [], displayNames: [],
symbols: [], symbols: [],
imports: [], imports: [],
@@ -539,7 +540,7 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () =
it('同一个名字:方法说明里的找得到同类的另一个方法,顶层函数说明里的找不到;标坏链接、点了打开都按各自那块找', async () => { it('同一个名字:方法说明里的找得到同类的另一个方法,顶层函数说明里的找不到;标坏链接、点了打开都按各自那块找', async () => {
const blocks = [declared('class AgentService', 100, [declared(METHOD, 400)]), declared(HELPER, 950)] const blocks = [declared('class AgentService', 100, [declared(METHOD, 400)]), declared(HELPER, 950)]
vi.mocked(documentMessage).mockImplementationOnce(async input => ({ 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: [], displayNames: [],
symbols: [], symbols: [],
imports: [], imports: [],
@@ -38,10 +38,10 @@ import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups' import { DocumentLookups } from '../ruleLookups'
import { referenceScope, resolveReference } from '../symbolLinks' import { referenceScope, resolveReference } from '../symbolLinks'
import { definitionAction } from './definitionAction' 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 { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources' import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage' import { documentMessage, pageDocument } from './documentMessage'
import { documentSymbolNodes } from './documentSymbols' import { documentSymbolNodes } from './documentSymbols'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences' import { occurrencesOf } from './occurrences'
@@ -431,7 +431,7 @@ class Preview {
const reveal = this.pendingReveal const reveal = this.pendingReveal
this.pendingReveal = undefined this.pendingReveal = undefined
this.shown = version 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) if (this.sent)
void this.diff.update(this.sent) void this.diff.update(this.sent)
// 文档在解析期间又改了的,等下一次发文档再查 // 文档在解析期间又改了的,等下一次发文档再查
@@ -847,8 +847,10 @@ class Preview {
const path = workspace.asRelativePath(uri) const path = workspace.asRelativePath(uri)
if (uri.toString() === this.document.uri.toString()) { if (uri.toString() === this.document.uri.toString()) {
const { sent } = this const { sent } = this
const block = sent?.version === target.version ? peekBlock(sent.blocks, offset, line) : undefined if (sent?.version !== target.version)
return block && { path, line, block, names: namesOnCard(block, this.names.known(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( const { message, displayNames } = await documentMessage(
{ languageId: target.languageId, version: target.version, getText: () => target.getText(), fileName: target.fileName, entryNames: this.owner.entries.namesOf(uri) }, { 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 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) { catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` }) logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` })
@@ -7,6 +7,7 @@ import { act } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { App } from './App' import { App } from './App'
import { NO_SPAN } from './testBlocks'
import { createViewStateStore } from './viewState' import { createViewStateStore } from './viewState'
/** /**
@@ -118,8 +119,7 @@ function block(id: string, anchor: string): BlockView {
anchor, anchor,
kind: 'function', kind: 'function',
callTree: {}, callTree: {},
startLine: 0, span: NO_SPAN,
endLine: 1,
attributes: { kind: 'function' }, attributes: { kind: 'function' },
concepts: [{ id: 'function', label: 'function', icon: 'braces' }], concepts: [{ id: 'function', label: 'function', icon: 'braces' }],
children: [], children: [],
+12 -8
View File
@@ -19,6 +19,7 @@ import { FilterBar } from './FilterBar'
import { OverviewRuler } from './OverviewRuler' import { OverviewRuler } from './OverviewRuler'
import { pageLayout } from './pageLayout' import { pageLayout } from './pageLayout'
import { createPageStore } from './pageState' import { createPageStore } from './pageState'
import { NO_PLACEMENT, placementOf } from './placement'
import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa' import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa'
import { TextMarksContext } from './SegmentText' import { TextMarksContext } from './SegmentText'
import { shareUnchanged } from './sharing' import { shareUnchanged } from './sharing'
@@ -71,7 +72,7 @@ export function App(): ReactElement | null {
const rewrite = useRewrite() const rewrite = useRewrite()
const [page] = useState(() => createPageStore(viewState.get())) const [page] = useState(() => createPageStore(viewState.get()))
/** 页面上文字的标记:这个文档版本的中文名(文档带来的,扩展端查到了再换)和单击高亮的名字;各段文字按自己那几段订阅 */ /** 页面上文字的标记:这个文档版本的中文名(文档带来的,扩展端查到了再换)和单击高亮的名字;各段文字按自己那几段订阅 */
const [marks] = useState(() => createStore<TextMarks>({ names: new Map() })) const [marks] = useState(() => createStore<TextMarks>({ names: new Map(), placement: NO_PLACEMENT }))
/** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */ /** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */
const restoreCall = useRef(viewState.get().callTree) const restoreCall = useRef(viewState.get().callTree)
/** 块树所在的页面内容:钉住卡片时在这里面量 */ /** 块树所在的页面内容:钉住卡片时在这里面量 */
@@ -79,11 +80,13 @@ export function App(): ReactElement | null {
/** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来 */ /** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来 */
const attachContent = useCallback((el: HTMLDivElement | null) => { const attachContent = useCallback((el: HTMLDivElement | null) => {
content.current = el content.current = el
return el ? pageLayout.attach(el, document.querySelector<HTMLElement>('.cr-filter-bar')) : undefined return el ? pageLayout.attach(el, document.querySelector<HTMLElement>('.cr-filter-bar'), () => page.get().placement) : undefined
}, []) }, [])
// 这一层重画了:同一段脚本里接着要量位置的(滚到某一行、跳到改动、滚到当前那一处)不能用重画前量的,先作废;放在它们前面 // 这一层重画了:同一段脚本里接着要量位置的(滚到某一行、跳到改动、滚到当前那一处)不能用重画前量的,先作废;放在它们前面
useLayoutEffect(() => pageLayout.invalidate()) 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 pathChat = useStore(qa.store, state => chatOf(state, PATH_ASK_ANCHOR), sameChat)
const qaAvailable = useStore(qa.store, state => state.available) const qaAvailable = useStore(qa.store, state => state.available)
const qaSubject = useStore(qa.store, state => state.subject) 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]) const revealed = useCallback(() => page.update({ reveal: null }), [page])
useScrollPosition(doc) useScrollPosition(doc)
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed) useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
useDefinitionLinks(doc?.version) useDefinitionLinks(doc?.version, currentPlacement)
const peek = useDefinitionPeek(doc?.version) const peek = useDefinitionPeek(doc?.version, currentPlacement)
const sticky = useStickySettings() const sticky = useStickySettings()
useRepoLinks() useRepoLinks()
useRepoImages() useRepoImages()
@@ -106,11 +109,12 @@ export function App(): ReactElement | null {
const occurrencePage = useMemo((): OccurrencePage | undefined => doc const occurrencePage = useMemo((): OccurrencePage | undefined => doc
? { ? {
blocks: doc.blocks, blocks: doc.blocks,
placement,
skip: id => unshown.has(id) || covered.has(id) || editing.includes(id), skip: id => unshown.has(id) || covered.has(id) || editing.includes(id),
collapsed, collapsed,
expand, 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) const occurrences = useOccurrences(doc?.version, doc?.translatorId, occurrencePage, marks)
/** 块树:状态是页面 store 里的 tree,动作改页面 store;整页一份,卡片拿到的回调一直是同一个 */ /** 块树:状态是页面 store 里的 tree,动作改页面 store;整页一份,卡片拿到的回调一直是同一个 */
@@ -124,7 +128,7 @@ export function App(): ReactElement | null {
const leaving = current.editing.includes(id) const leaving = current.editing.includes(id)
return { return {
focusId: leaving ? null : id, 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 }), onFocused: () => page.update({ focusId: null }),
@@ -150,7 +154,7 @@ export function App(): ReactElement | null {
// 和上一份内容一样的块还是原来的对象:没变的卡片不重画 // 和上一份内容一样的块还是原来的对象:没变的卡片不重画
const next = shareUnchanged(page.get().doc, msg) const next = shareUnchanged(page.get().doc, msg)
const reopened = restoring && openingCall(next, restoring.anchor, restoring.entry) 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 => ({ page.update(current => ({
doc: next, doc: next,
keep: keptWhileEditing(current.keep, current.editing), keep: keptWhileEditing(current.keep, current.editing),
@@ -7,6 +7,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { fileAttributes } from '../../../src/codeReader/attributes' import { fileAttributes } from '../../../src/codeReader/attributes'
import { readBlockViews, toBlockViews } from '../../../src/codeReader/blockView' import { readBlockViews, toBlockViews } from '../../../src/codeReader/blockView'
import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks'
import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { fastapiRule } from '../../../src/codeReader/rules/fastapi' import { fastapiRule } from '../../../src/codeReader/rules/fastapi'
import { runtime, WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' 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 { BlockTree, treeState } from './BlockTree'
import { applyFilter } from './filter' import { applyFilter } from './filter'
import { FilterBar } from './FilterBar' import { FilterBar } from './FilterBar'
import { NO_PLACEMENT, placementOf } from './placement'
import { EMPTY_CHAT } from './qa' import { EMPTY_CHAT } from './qa'
import { staticStore } from './store' import { staticStore } from './store'
import { NO_SPAN } from './testBlocks'
import { collapsedIds } from './viewState' import { collapsedIds } from './viewState'
// 编辑框连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着 // 编辑框连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
@@ -29,8 +32,7 @@ function view(id: string, kind: string, text: string, extra: Partial<BlockView>
id, id,
anchor: id, anchor: id,
kind, kind,
startLine: 0, span: NO_SPAN,
endLine: 0,
attributes: { kind }, attributes: { kind },
concepts: [{ id: kind, label: badge?.label ?? kind, icon: badge?.icon ?? 'box', ...(badge && { color: badge.color }) }], concepts: [{ id: kind, label: badge?.label ?? kind, icon: badge?.icon ?? 'box', ...(badge && { color: badge.color }) }],
children: [], children: [],
@@ -66,7 +68,7 @@ function rewriteTree(state: ChatState): RewriteTree {
function tree(view: Partial<Omit<BlockTreeState, 'folds'>> = {}, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeHandle { function tree(view: Partial<Omit<BlockTreeState, 'folds'>> = {}, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeHandle {
return { return {
state: staticStore(treeState({ 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(), collapsed: new Set(),
hidden: new Set(), hidden: new Set(),
temporary: new Set(), temporary: new Set(),
@@ -103,16 +105,17 @@ describe('路由筛选后展开函数体', () => {
' return "unrelated-body"', ' return "unrelated-body"',
].join('\n') ].join('\n')
const language = createPythonAdapter(WASM_DIR) const language = createPythonAdapter(WASM_DIR)
const blocks = await withBlocks(language, 'python', source, parsed => toBlockViews( const views = await withBlocks(language, 'python', source, parsed => toBlockViews(
parsed, parsed,
{ source, language, markers: [] }, { source, language, markers: [] },
pythonZhTranslator, pythonZhTranslator,
{ rules: [fastapiRule], report: (message) => { throw new Error(message) } }, { rules: [fastapiRule], report: (message) => { throw new Error(message) } },
)) ))
const blocks = pageBlocks(views, source)
const route = blocks.find(block => block.kind === 'fastapi.route')! const route = blocks.find(block => block.kind === 'fastapi.route')!
expect(route.children.map(block => block.kind)).toEqual(['if', 'return']) expect(route.children.map(block => block.kind)).toEqual(['if', 'return'])
expect(route.children[0].children.map(block => block.kind)).toEqual(['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 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 filtered = applyFilter(blocks, excluded, new Set())
const view = { collapsed: collapsedIds(blocks, {}), hidden: filtered.hidden, temporary: filtered.temporary } const view = { collapsed: collapsedIds(blocks, {}), hidden: filtered.hidden, temporary: filtered.temporary }
@@ -195,7 +198,7 @@ describe('「注释」一行和「重写注释」按钮', () => {
it('属性表卡片:「注释」一行照属性表画,按钮在右上角那排,不在这一行上', () => { it('属性表卡片:「注释」一行照属性表画,按钮在右上角那排,不在这一行上', () => {
const card = view('1', 'function', '', { const card = view('1', 'function', '', {
properties: [{ label: '名称', value: [{ text: 'find', role: 'function' }] }, comment, { label: '异步', value: [{ text: '否', role: 'storage' }] }], 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) const markup = html(card)
inToolbar(markup, '按注释规范重写') inToolbar(markup, '按注释规范重写')
@@ -203,13 +206,13 @@ describe('「注释」一行和「重写注释」按钮', () => {
}) })
it('一行译文的卡片:「注释」一行排在译文下面,按钮同样在右上角那排', () => { 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('按名字找')) expect(markup.indexOf('save()')).toBeLessThan(markup.indexOf('按名字找'))
inToolbar(markup, '按注释规范重写') inToolbar(markup, '按注释规范重写')
}) })
it('重写过的块:下面一行「重写过 N 次」带灰字历史;记录收着时按钮先展开它,展开着时再点才重写', () => { 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 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 closed = { ...EMPTY_CHAT, available: true, threads: [{ anchor: block.anchor, entries: [entry] }] }
const collapsedMarkup = markup([block], tree({}, closed)) const collapsedMarkup = markup([block], tree({}, closed))
@@ -222,7 +225,7 @@ describe('「注释」一行和「重写注释」按钮', () => {
}) })
it('展开着又在写或排着队时按钮不能点;出错、停下的不挡', () => { 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 button = (status: Ask['status']): string => {
const rewrite = { ...EMPTY_CHAT, available: true, open: [block.anchor], asks: [{ askId: 'r1', anchor: block.anchor, question: 'q', text: '', status }] } const rewrite = { ...EMPTY_CHAT, available: true, open: [block.anchor], asks: [{ askId: 'r1', anchor: block.anchor, question: 'q', text: '', status }] }
return /<button[^>]*aria-label="按注释规范重写"[^>]*>/.exec(markup([block], tree({}, rewrite)))?.[0] ?? '' return /<button[^>]*aria-label="按注释规范重写"[^>]*>/.exec(markup([block], tree({}, rewrite)))?.[0] ?? ''
@@ -233,7 +236,7 @@ describe('「注释」一行和「重写注释」按钮', () => {
}) })
it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => { 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('按注释规范') expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范')
}) })
}) })
@@ -243,7 +246,7 @@ describe('一行译文下面拆开的各项', () => {
async function returned(source: string): Promise<BlockView> { async function returned(source: string): Promise<BlockView> {
const language = createPythonAdapter(WASM_DIR) 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)) 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 = [ const SOURCE = [
@@ -323,9 +326,10 @@ describe('一行译文下面拆开的各项', () => {
describe('编辑框', () => { describe('编辑框', () => {
it('编辑着的块:编辑框里是文档源码按它的编辑范围取的整行、去掉共同缩进,着色取自整份源码的着色', async () => { it('编辑着的块:编辑框里是文档源码按它的编辑范围取的整行、去掉共同缩进,着色取自整份源码的着色', async () => {
const source = 'def f(x):\n if x:\n return "a"\n' 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({ 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(), collapsed: new Set(),
})) }))
expect(html).toContain('>if x:\n return &quot;a&quot;</textarea>') expect(html).toContain('>if x:\n return &quot;a&quot;</textarea>')
+24 -15
View File
@@ -2,11 +2,12 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { SourceText } from './blockSource' import type { SourceText } from './blockSource'
import type { RewriteTool } from './BlockTools' import type { RewriteTool } from './BlockTools'
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView' import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
import type { Place, Placement } from './placement'
import type { GridSlots } from './PropertyGrid' import type { GridSlots } from './PropertyGrid'
import type { ChatState, ChatView, QaState } from './qa' import type { ChatState, ChatView, QaState } from './qa'
import type { PendingImage } from './qaImages' import type { PendingImage } from './qaImages'
import type { ReadableStore } from './store' 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 { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
import { createContext, Fragment, memo, useContext, useMemo, useState } from 'react' import { createContext, Fragment, memo, useContext, useMemo, useState } from 'react'
import { AskThread, REWRITE_THREAD } from './AskThread' import { AskThread, REWRITE_THREAD } from './AskThread'
@@ -18,6 +19,7 @@ import { estimatedHeight } from './itemHeight'
import { KindIcon } from './kindIcons' import { KindIcon } from './kindIcons'
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid' import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa' import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa'
import { BlockIdContext } from './SegmentText'
import { shallowEqual, staticStore, useStore } from './store' import { shallowEqual, staticStore, useStore } from './store'
import { TextPreview } from './TextPreview' import { TextPreview } from './TextPreview'
import { foldsByCard, listId, tableId } from './viewState' import { foldsByCard, listId, tableId } from './viewState'
@@ -27,6 +29,8 @@ export interface EditState {
version: number version: number
/** 这一版文档的源码和着色:编辑框按块的编辑范围从里面取 */ /** 这一版文档的源码和着色:编辑框按块的编辑范围从里面取 */
sourceText: SourceText sourceText: SourceText
/** 这一版文档里各块在文件里的位置:编辑范围、发回改动时的行号按它算 */
placement: Placement
editing: ReadonlySet<string> editing: ReadonlySet<string>
/** 刚点了「编辑」、要把光标放进去的块 */ /** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null focusId: string | null
@@ -139,6 +143,7 @@ export function treeState(state: Omit<BlockTreeState, 'folds'>, previous?: Block
} }
const NO_FOLDS: ReadonlySet<string> = new Set() const NO_FOLDS: ReadonlySet<string> = new Set()
const NO_CHILDREN: BlockView[] = []
const NO_REMOVED: readonly PlacedRemoved[] = [] const NO_REMOVED: readonly PlacedRemoved[] = []
/** 没有问 AI、重写注释的块树(定义卡片浮窗):一直不能用 */ /** 没有问 AI、重写注释的块树(定义卡片浮窗):一直不能用 */
const NO_CHAT: ReadableStore<ChatState> = staticStore(EMPTY_CHAT) const NO_CHAT: ReadableStore<ChatState> = staticStore(EMPTY_CHAT)
@@ -211,8 +216,9 @@ interface BlockShape {
focus: boolean focus: boolean
/** 编辑框每来一份新文档都要对一次内容;不在编辑的卡片不跟着版本重画 */ /** 编辑框每来一份新文档都要对一次内容;不在编辑的卡片不跟着版本重画 */
version?: number version?: number
/** 编辑着的卡片才有:这一版文档的源码和着色 */ /** 编辑着的卡片才有:这一版文档的源码和着色,这块在文件里的位置 */
sourceText?: SourceText sourceText?: SourceText
place?: Place
/** 调用树:扩展端标了能看调用树(BlockView.callTree)的块才有棵数 */ /** 调用树:扩展端标了能看调用树(BlockView.callTree)的块才有棵数 */
calls?: number calls?: number
callOpen: boolean callOpen: boolean
@@ -235,6 +241,7 @@ function shapeOf(block: BlockView, state: BlockTreeState): BlockShape {
focus: state.edit?.focusId === block.id, focus: state.edit?.focusId === block.id,
version: editing ? state.edit?.version : undefined, version: editing ? state.edit?.version : undefined,
sourceText: editing ? state.edit?.sourceText : 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, calls: state.callTree && block.callTree ? state.callTree.counts.get(block.anchor) ?? 0 : undefined,
callOpen: state.callTree?.openAnchor === block.anchor, callOpen: state.callTree?.openAnchor === block.anchor,
} }
@@ -273,7 +280,7 @@ function rewriteOf(block: BlockView, state: ChatState): RewriteShape {
return { return {
...(block.commentTarget && { ...(block.commentTarget && {
tool: { tool: {
fresh: block.commentTarget.kind === 'insert', fresh: block.commentTarget === 'insert',
rewriting: chat.open && chat.asks.some(unfinished), rewriting: chat.open && chat.asks.some(unfinished),
reopen: !chat.open && (chat.asks.length > 0 || chat.history.length > 0), 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 && <UnchangedStrip count={shape.run} onOpen={() => tree.onOpenRun(block.id)} />} {shape.run !== undefined && <UnchangedStrip count={shape.run} onOpen={() => tree.onOpenRun(block.id)} />}
{shape.shown && ( {shape.shown && (
<Item item={item} line={block.startLine} height={item ? estimatedHeight(block, shape.open) : 0}> <Item item={item} height={item ? estimatedHeight(block, shape.open) : 0}>
<BlockCard block={block} shape={shape} ask={ask} rewrite={rewriting} /> <BlockCard block={block} shape={shape} ask={ask} rewrite={rewriting} />
{/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */} {/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */}
{qa && ask.chat && ( {qa && ask.chat && (
@@ -355,10 +362,10 @@ BlockItem.displayName = 'BlockItem'
* 顶层一块的外层(见 BlockTree 的 items);不套时原样。离视口远时浏览器跳过它里面的样式、排版和绘制(.cr-item 的 content-visibility), * 顶层一块的外层(见 BlockTree 的 items);不套时原样。离视口远时浏览器跳过它里面的样式、排版和绘制(.cr-item 的 content-visibility),
* 没画过时按估的高度(height,见 estimatedHeight)占位,画过一次就记住真实高度。 * 没画过时按估的高度(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) if (!item)
return <>{children}</> return <>{children}</>
return <div className="cr-item flex flex-col gap-2" data-line={line} style={{ containIntrinsicBlockSize: `auto ${height}px` }}>{children}</div> return <div className="cr-item flex flex-col gap-2" style={{ containIntrinsicBlockSize: `auto ${height}px` }}>{children}</div>
} }
/** /**
@@ -367,7 +374,7 @@ function Item({ item, line, height, children }: { item: boolean, line: number, h
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。 * 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。items:这一层每块套一层 .cr-item(只有页面上的顶层)。 * 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() const tree = useBlockTree()
// 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层 // 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
const listed = useMemo(() => slotted ? blocks : blocks.filter(block => !block.slot), [blocks, 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 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。 * folds 是这张卡片里折叠着的(见 foldsByCard);标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
* children 是这块的子块,挂在子行上的画在属性表里(删掉的块不画子块,没有)。
*/ */
const BlockPreview = memo(({ block, folds, marks }: { block: BlockView, folds: ReadonlySet<string>, marks?: BlockMarks }): ReactElement => { const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { block: CardView, children?: BlockView[], folds: ReadonlySet<string>, marks?: BlockMarks }): ReactElement => {
const tree = useBlockTree() 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) if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={slots} /> return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={slots} />
const folding = { collapsed: folds, onToggleFold: tree.onToggleFold } const folding = { collapsed: folds, onToggleFold: tree.onToggleFold }
@@ -441,8 +449,8 @@ BlockPreview.displayName = 'BlockPreview'
* 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、 * 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、
* 同一行里别的收起,展开着的再点收起。卡片没有挂在子行上的子块时没有。 * 同一行里别的收起,展开着的再点收起。卡片没有挂在子行上的子块时没有。
*/ */
function cardSlots(block: BlockView, folds: ReadonlySet<string>, tree: BlockTreeHandle): GridSlots | undefined { function cardSlots(block: CardView, children: readonly BlockView[], folds: ReadonlySet<string>, tree: BlockTreeHandle): GridSlots | undefined {
const blocks = block.children.filter(child => child.slot) const blocks = children.filter(child => child.slot)
if (blocks.length === 0) if (blocks.length === 0)
return undefined return undefined
return { return {
@@ -473,12 +481,12 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl
const { change } = shape const { change } = shape
const { tool } = rewrite const { tool } = rewrite
return ( return (
<BlockIdContext value={block.id}>
<div <div
className="cr-block relative flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2" className="cr-block relative flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
{...kindProps(block)} {...kindProps(block)}
data-id={block.id} data-id={block.id}
data-anchor={anchor} data-anchor={anchor}
data-line={block.startLine}
data-editing={shape.editing ? '' : undefined} data-editing={shape.editing ? '' : undefined}
data-change={change?.change} data-change={change?.change}
> >
@@ -499,18 +507,18 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl
: <span className="w-3.5 shrink-0" />} : <span className="w-3.5 shrink-0" />}
<KindIcon concepts={block.concepts} /> <KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{edit && shape.version !== undefined && shape.sourceText {edit && shape.version !== undefined && shape.sourceText && shape.place
? ( ? (
<CodeEditor <CodeEditor
block={block}
sourceText={shape.sourceText} sourceText={shape.sourceText}
place={shape.place}
version={shape.version} version={shape.version}
autoFocus={shape.focus} autoFocus={shape.focus}
onAutoFocused={edit.onFocused} onAutoFocused={edit.onFocused}
onExit={() => edit.onToggleEdit(block.id)} onExit={() => edit.onToggleEdit(block.id)}
/> />
) )
: <BlockPreview block={block} folds={shape.folds} marks={shape.current ? change?.marks : undefined} />} : <BlockPreview block={block} children={block.children} folds={shape.folds} marks={shape.current ? change?.marks : undefined} />}
</div> </div>
{shape.temporary && ( {shape.temporary && (
<span className="cr-temporary flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] whitespace-nowrap"> <span className="cr-temporary flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] whitespace-nowrap">
@@ -529,6 +537,7 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl
/> />
)} )}
</div> </div>
</BlockIdContext>
) )
} }
@@ -9,7 +9,9 @@ import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockTree, treeState } from './BlockTree' import { BlockTree, treeState } from './BlockTree'
import { CallTreeDialog, splitWhere } from './CallTreeDialog' import { CallTreeDialog, splitWhere } from './CallTreeDialog'
import { NO_PLACEMENT } from './placement'
import { staticStore } from './store' import { staticStore } from './store'
import { NO_SPAN } from './testBlocks'
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) 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, anchor: id,
kind, kind,
...(callTree && { callTree }), ...(callTree && { callTree }),
startLine: 0, span: NO_SPAN,
endLine: 1,
attributes: { kind }, attributes: { kind },
concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }], concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }],
children: [], 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 { function tree(callTree?: CallTreeState & { onOpen: (anchor: string) => void }, edit = true): BlockTreeHandle {
return { return {
state: staticStore(treeState({ state: staticStore(treeState({
...(edit && { edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, editing: new Set<string>(), focusId: null } }), ...(edit && { edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, placement: NO_PLACEMENT, editing: new Set<string>(), focusId: null } }),
collapsed: new Set(), collapsed: new Set(),
hidden: new Set(), hidden: new Set(),
temporary: new Set(), temporary: new Set(),
@@ -1,6 +1,6 @@
import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react' import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react'
import type { SourceText } from './blockSource' import type { SourceText } from './blockSource'
import type { BlockView } from './types' import type { Place } from './placement'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { blockSource } from './blockSource' import { blockSource } from './blockSource'
import { recolor } from './segments' import { recolor } from './segments'
@@ -10,9 +10,10 @@ import { postMessage } from './vscode'
const EDIT_DEBOUNCE_MS = 200 const EDIT_DEBOUNCE_MS = 200
interface CodeEditorProps { interface CodeEditorProps {
block: BlockView
/** 这一版文档的源码和着色:这块的源码按它的编辑范围从里面取 */ /** 这一版文档的源码和着色:这块的源码按它的编辑范围从里面取 */
sourceText: SourceText sourceText: SourceText
/** 这块在这一版文档里的位置:编辑范围是它的首尾行 */
place: Place
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */ /** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */
version: number version: number
autoFocus: boolean autoFocus: boolean
@@ -21,12 +22,12 @@ interface CodeEditorProps {
} }
/** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */ /** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */
export function CodeEditor({ block, sourceText, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement { export function CodeEditor({ sourceText, place, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
const { code, tokens } = useMemo(() => blockSource(sourceText, block.startLine, block.endLine), [sourceText, block.startLine, block.endLine]) const { code, tokens } = useMemo(() => blockSource(sourceText, place.line, place.endLine), [sourceText, place.line, place.endLine])
const [text, setText] = useState(code) const [text, setText] = useState(code)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const blockRef = useRef(block) const placeRef = useRef(place)
blockRef.current = block placeRef.current = place
const textRef = useRef(text) const textRef = useRef(text)
textRef.current = text textRef.current = text
/** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */ /** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */
@@ -42,12 +43,12 @@ export function CodeEditor({ block, sourceText, version, autoFocus, onAutoFocuse
if (!unsent.current) if (!unsent.current)
return return
unsent.current = false unsent.current = false
const { startLine, endLine } = blockRef.current const { line, endLine } = placeRef.current
const value = textRef.current const value = textRef.current
postMessage({ postMessage({
type: 'edit', type: 'edit',
startLine, startLine: line,
endLine: sentLineCount.current === null ? endLine : startLine + sentLineCount.current - 1, endLine: sentLineCount.current === null ? endLine : line + sentLineCount.current - 1,
text: value, text: value,
}) })
sentLineCount.current = value.split('\n').length sentLineCount.current = value.split('\n').length
@@ -2,6 +2,7 @@ import type { BlockView, DefinitionPeek } from './types'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { PeekTree } from './DefinitionPeekPopup' import { PeekTree } from './DefinitionPeekPopup'
import { NO_SPAN } from './testBlocks'
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
@@ -10,8 +11,7 @@ function block(id: string, kind: string, extra: Partial<BlockView>): BlockView {
id, id,
anchor: id, anchor: id,
kind, kind,
startLine: 0, span: NO_SPAN,
endLine: 0,
attributes: { kind }, attributes: { kind },
concepts: [{ id: kind, label: kind, icon: 'box' }], concepts: [{ id: kind, label: kind, icon: 'box' }],
children: [], children: [],
@@ -7,6 +7,7 @@ import { createPortal } from 'react-dom'
import { BlockTree, treeState } from './BlockTree' import { BlockTree, treeState } from './BlockTree'
import { PEEK_ATTRIBUTE, placePeek } from './definitionPeek' import { PEEK_ATTRIBUTE, placePeek } from './definitionPeek'
import { nameMap } from './displayNames' import { nameMap } from './displayNames'
import { placementOf } from './placement'
import { TextMarksContext } from './SegmentText' import { TextMarksContext } from './SegmentText'
import { createStore, staticStore } from './store' import { createStore, staticStore } from './store'
import { MarkdownKindContext } from './TextPreview' import { MarkdownKindContext } from './TextPreview'
@@ -37,11 +38,13 @@ export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement {
onOpenRun: () => {}, 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 ( return (
<TextMarksContext value={marks}> <TextMarksContext value={marks}>
<MarkdownKindContext value="peek"> <MarkdownKindContext value="peek">
<BlockTree blocks={[peek.block]} tree={tree} /> <BlockTree blocks={blocks} tree={tree} />
</MarkdownKindContext> </MarkdownKindContext>
</TextMarksContext> </TextMarksContext>
) )
@@ -2,36 +2,50 @@ import type { LucideIcon } from 'lucide-react'
import type { CSSProperties, ReactElement } from 'react' import type { CSSProperties, ReactElement } from 'react'
import type { NameMap } from './displayNames' import type { NameMap } from './displayNames'
import type { Highlight, OccurrenceStyle } from './occurrences' import type { Highlight, OccurrenceStyle } from './occurrences'
import type { Placement } from './placement'
import type { ReadableStore } from './store' import type { ReadableStore } from './store'
import type { GroupStyle, Inline, Segment, SegmentGroup, ShownName } from './types' import type { GroupStyle, Inline, Segment, SegmentGroup, ShownName } from './types'
import { createContext, memo, useContext, useState } from 'react' import { createContext, memo, useContext, useState } from 'react'
import { sameShownName, shownName } from './displayNames' import { sameShownName, shownName } from './displayNames'
import { KIND_ICON_COMPONENTS } from './kindIcons' import { KIND_ICON_COMPONENTS } from './kindIcons'
import { isCurrent, segmentOccurrence, sourceOf } from './occurrences' import { isCurrent, segmentOccurrence, sourceOf } from './occurrences'
import { NO_PLACEMENT } from './placement'
import { staticStore, useStore } from './store' import { staticStore, useStore } from './store'
/** 页面上文字的两样标记:中文名(下标 → 显示成什么),眼下高亮着的名字(没有高亮时没有)。 */ /**
* 页面上文字的标记:中文名(文件里的下标 → 显示成什么),眼下高亮着的名字(没有高亮时没有),和各块在文件里的位置
* (文字里的下标从所在那块的开头算,按它换成文件里的下标再去查)。
*/
export interface TextMarks { export interface TextMarks {
names: NameMap names: NameMap
highlight?: Highlight highlight?: Highlight
placement: Placement
} }
/** /**
* 文字的标记从哪份 store 读:页面上是 App 的那份(中文名、单击高亮都记在里面);定义卡片浮窗是那个文件的中文名,钉住的行不画高亮。 * 文字的标记从哪份 store 读:页面上是 App 的那份(中文名、单击高亮都记在里面);定义卡片浮窗是那个文件的中文名,钉住的行不画高亮。
* 每段文字只订阅自己那几段的标记。 * 每段文字只订阅自己那几段的标记。
*/ */
export const TextMarksContext = createContext<ReadableStore<TextMarks>>(staticStore({ names: new Map() })) export const TextMarksContext = createContext<ReadableStore<TextMarks>>(staticStore({ names: new Map(), placement: NO_PLACEMENT }))
/** 文字在哪块的卡片里(块 id):卡片、钉住的卡片头按它给里面的文字;卡片外面的文字(编辑框的着色)不带下标,没有 */
export const BlockIdContext = createContext<string | undefined>(undefined)
/** /**
* 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换,带自己颜色(color)的段用它(.cr-colored);带源码下标的名字记在 data-offset 上 * 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换,带自己颜色(color)的段用它(.cr-colored);带下标的名字把块里的下标记在 data-offset 上
* (讲别处名字的段记那个名字的下标,见 sourceOf),跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时 * (讲别处名字的段记那个名字的下标,见 sourceOf;换成文件里的见 nameOffset),跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时
* 再标 data-current。起了中文名的名字显示中文名(data-name),悬停看原名,定义处本身在后面淡淡写原名;下标照旧,跳到定义、高亮照常。 * 再标 data-current。起了中文名的名字显示中文名(data-name),悬停看原名,定义处本身在后面淡淡写原名;下标照旧,跳到定义、高亮照常。
* 带悬停提示(tooltip)的段悬停看它。 * 带悬停提示(tooltip)的段悬停看它。
* 框起来的一组词画成小框,框里的词同样处理。 * 框起来的一组词画成小框,框里的词同样处理。
* 中文名和高亮只订阅自己这几段的(TextMarksContext):中文名到了、换了高亮的名字,只有字或底色变了的那几串文字重画。 * 中文名和高亮只订阅自己这几段的(TextMarksContext),按所在那块(BlockIdContext)在文件里的位置查:中文名到了、换了高亮的名字,只有字或底色变了的那几串文字重画;
* 块只是挪了位置时查出来的还是那些,不重画。
*/ */
export const SegmentText = memo(({ segments }: { segments: Inline[] }): ReactElement => { 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 ( return (
<> <>
{segments.map((piece, index) => { {segments.map((piece, index) => {
@@ -44,7 +58,7 @@ export const SegmentText = memo(({ segments }: { segments: Inline[] }): ReactEle
key={index} key={index}
className={piece.color ? `cr-role-${piece.role} cr-colored` : `cr-role-${piece.role}`} 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} 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-occurrence={mark?.occurrence}
data-current={mark?.current ? '' : undefined} data-current={mark?.current ? '' : undefined}
data-name={shown?.name} data-name={shown?.name}
@@ -67,10 +81,11 @@ interface SegmentMark {
current: boolean current: boolean
} }
function markOf(piece: Segment, { names, highlight }: TextMarks): SegmentMark | undefined { /** 一段的标记;段里的下标从所在那块的开头算,那块的开头在文件里的下标是 base。 */
const shown = shownName(piece, names) function markOf(piece: Segment, base: number, { names, highlight }: TextMarks): SegmentMark | undefined {
const occurrence = highlight && segmentOccurrence(piece, highlight.occurrences) const shown = shownName(piece, base, names)
const current = highlight !== undefined && isCurrent(piece, highlight) const occurrence = highlight && segmentOccurrence(piece, base, highlight.occurrences)
const current = highlight !== undefined && isCurrent(piece, base, highlight)
return shown || occurrence || current ? { shown, occurrence, current } : undefined return shown || occurrence || current ? { shown, occurrence, current } : undefined
} }
@@ -2,9 +2,11 @@ import type { BlockView } from './types'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { nameMap } from './displayNames' import { nameMap } from './displayNames'
import { placementOf } from './placement'
import { TextMarksContext } from './SegmentText' import { TextMarksContext } from './SegmentText'
import { PinnedHead } from './StickyLines' import { PinnedHead } from './StickyLines'
import { staticStore } from './store' import { staticStore } from './store'
import { NO_SPAN } from './testBlocks'
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
@@ -13,8 +15,7 @@ function block(kind: string, extra: Partial<BlockView>): BlockView {
id: '2', id: '2',
anchor: '2', anchor: '2',
kind, kind,
startLine: 0, span: NO_SPAN,
endLine: 0,
attributes: { kind }, attributes: { kind },
concepts: [{ id: kind, label: kind, icon: 'square-function' }], concepts: [{ id: kind, label: kind, icon: 'square-function' }],
children: [], children: [],
@@ -25,7 +26,7 @@ function block(kind: string, extra: Partial<BlockView>): BlockView {
function render(view: BlockView): string { function render(view: BlockView): string {
return renderToStaticMarkup( return renderToStaticMarkup(
<TextMarksContext value={staticStore({ names: nameMap([{ offset: 40, length: 10, name: '加载配置', definition: true }]) })}> <TextMarksContext value={staticStore({ names: nameMap([{ offset: 40, length: 10, name: '加载配置', definition: true }]), placement: placementOf([view]) })}>
<PinnedHead block={view} /> <PinnedHead block={view} />
</TextMarksContext>, </TextMarksContext>,
) )
@@ -3,7 +3,7 @@ import type { BlockView, StickyScrollSettings } from './types'
import { useContext, useLayoutEffect, useMemo } from 'react' import { useContext, useLayoutEffect, useMemo } from 'react'
import { KindIcon } from './kindIcons' import { KindIcon } from './kindIcons'
import { pageLayout } from './pageLayout' import { pageLayout } from './pageLayout'
import { SegmentText, TextMarksContext } from './SegmentText' import { BlockIdContext, SegmentText, TextMarksContext } from './SegmentText'
import { selectStore } from './store' import { selectStore } from './store'
import { flash } from './useReveal' import { flash } from './useReveal'
import { usePinnedHeight } from './useScrollPosition' import { usePinnedHeight } from './useScrollPosition'
@@ -24,11 +24,12 @@ function blocksById(blocks: readonly BlockView[]): Map<string, BlockView> {
/** /**
* 钉住的一行里卡片的头:折叠箭头那格留空、概念图标、标题,各自的位置同卡片上的,一行放不下省略。 * 钉住的一行里卡片的头:折叠箭头那格留空、概念图标、标题,各自的位置同卡片上的,一行放不下省略。
* 标题同卡片上的写法(中文名照换):属性表的第一行(名称)的值,或一行译文;Markdown 只取第一行。 * 标题同卡片上的写法(中文名照换,标题里的下标同卡片上的从这块的开头算):属性表的第一行(名称)的值,或一行译文;Markdown 只取第一行。
*/ */
export function PinnedHead({ block }: { block: BlockView }): ReactElement { export function PinnedHead({ block }: { block: BlockView }): ReactElement {
const title = 'properties' in block ? block.properties[0]?.value : block.translation const title = 'properties' in block ? block.properties[0]?.value : block.translation
return ( return (
<BlockIdContext value={block.id}>
<div className="flex h-full min-w-0 items-center gap-2.5 pr-2.5 pl-[9px]"> <div className="flex h-full min-w-0 items-center gap-2.5 pr-2.5 pl-[9px]">
<span className="w-3.5 shrink-0" /> <span className="w-3.5 shrink-0" />
<KindIcon concepts={block.concepts} /> <KindIcon concepts={block.concepts} />
@@ -38,6 +39,7 @@ export function PinnedHead({ block }: { block: BlockView }): ReactElement {
: <SegmentText segments={title} />)} : <SegmentText segments={title} />)}
</p> </p>
</div> </div>
</BlockIdContext>
) )
} }
@@ -55,7 +57,7 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject<
const byId = useMemo(() => blocksById(blocks), [blocks]) const byId = useMemo(() => blocksById(blocks), [blocks])
// 钉着的名字照换中文名,不画页面上的高亮 // 钉着的名字照换中文名,不画页面上的高亮
const marks = useContext(TextMarksContext) 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 count = lines.scopes.length
const height = count * STICKY_LINE_HEIGHT + lines.offset const height = count * STICKY_LINE_HEIGHT + lines.offset
@@ -90,6 +92,7 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject<
<div <div
key={scope.id} key={scope.id}
className="cr-sticky-line" className="cr-sticky-line"
data-id={scope.id}
data-last={last ? '' : undefined} data-last={last ? '' : undefined}
style={{ top: index * STICKY_LINE_HEIGHT + (last ? lines.offset : 0), height: STICKY_LINE_HEIGHT }} style={{ top: index * STICKY_LINE_HEIGHT + (last ? lines.offset : 0), height: STICKY_LINE_HEIGHT }}
onClick={() => jump(scope.id)} onClick={() => jump(scope.id)}
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DefinitionPeeks, linkPeekSource } from './definitionPeek' import { DefinitionPeeks, linkPeekSource } from './definitionPeek'
import { DiffText } from './DiffText' import { DiffText } from './DiffText'
import { commentLinkOf } from './repoLink' import { commentLinkOf } from './repoLink'
import { NO_SPAN } from './testBlocks'
import { MarkdownKindContext, TextPreview } from './TextPreview' import { MarkdownKindContext, TextPreview } from './TextPreview'
let container: HTMLDivElement let container: HTMLDivElement
@@ -134,7 +135,7 @@ describe.each(['TextPreview', 'DiffText'] as const)('%s 的 Markdown 页面元
const peek: DefinitionPeek = { const peek: DefinitionPeek = {
path: 'client.py', path: 'client.py',
line: 1, 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: [], names: [],
} }
await act(async () => controller.answer(1, peek)) await act(async () => controller.answer(1, peek))
@@ -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 { describe, expect, it } from 'vitest'
import { changeStops, diffTree, foldUnchanged, nextStop, openedByDiff, removedTitle, statsParts } from './diffView' 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> = {}): BlockView { function block(id: string, anchor: string, children: WrittenBlock[] = [], extra: Partial<WrittenBlock> = {}): WrittenBlock {
return { id, anchor, kind: 'function', startLine: 0, endLine: 0, attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children, translation: [], ...extra } as BlockView 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 { function removed(parent: string, after: string | undefined, kind = 'function'): RemovedView {
@@ -14,10 +17,10 @@ function diff(parts: Partial<DocumentDiff>): DocumentDiff {
return { base: '上一次提交', version: 2, blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 }, lineCount: 10, ...parts } 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('0', 'function a', [block('0.0', 'function a > return @1')]),
block('1', 'function b'), block('1', 'function b'),
] ])
describe('diffTree', () => { describe('diffTree', () => {
it('块按记号认成页面上的 id;页面上找不到的记号(文档刚改)不标', () => { it('块按记号认成页面上的 id;页面上找不到的记号(文档刚改)不标', () => {
@@ -80,13 +83,13 @@ describe('removedTitle', () => {
* 3 function d(新增,带子块 3.0) * 3 function d(新增,带子块 3.0)
* 4 function e(没变) * 4 function e(没变)
*/ */
const TREE_BLOCKS = [ const TREE_BLOCKS = placed([
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('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', [], { startLine: 5 }), block('1', 'b', [], { lines: [5, 5] }),
block('2', 'c', [], { startLine: 7 }), block('2', 'c', [], { lines: [7, 7] }),
block('3', 'd', [block('3.0', 'd0')], { startLine: 9, endLine: 11 }), block('3', 'd', [block('3.0', 'd0')], { lines: [9, 11] }),
block('4', 'e', [], { startLine: 13 }), block('4', 'e', [], { lines: [13, 13] }),
] ])
function tree(removedList: RemovedView[] = []): NonNullable<ReturnType<typeof diffTree>> { function tree(removedList: RemovedView[] = []): NonNullable<ReturnType<typeof diffTree>> {
return diffTree(TREE_BLOCKS, diff({ return diffTree(TREE_BLOCKS, diff({
@@ -116,13 +119,13 @@ describe('foldUnchanged', () => {
}) })
/** 一张卡片:两个子块挂在第 8 行的子行上,后面一个留下的子块;只有第二个子块改了,它里面挂在第 2 行子行上的子块自己改了 */ /** 一张卡片:两个子块挂在第 8 行的子行上,后面一个留下的子块;只有第二个子块改了,它里面挂在第 2 行子行上的子块自己改了 */
const CARD_BLOCKS = [ const CARD_BLOCKS = placed([
block('0', 'card', [ block('0', 'card', [
block('0.0', 'm1', [], { slot: { row: 8, item: 0 } }), 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'), block('0.2', 'rest'),
]), ]),
] ])
function cardTree(): NonNullable<ReturnType<typeof diffTree>> { function cardTree(): NonNullable<ReturnType<typeof diffTree>> {
return diffTree(CARD_BLOCKS, diff({ blocks: { card: { change: 'mod' }, m2: { change: 'mod' }, m2s: { change: 'mod', own: true } } }), 2, () => true)! 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('上一处 / 下一处:要展开的是子块所在的那一行和它自己,不是卡片', () => { 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']], ['0.1.0', ['0/p8', '0.1', '0.1/p2', '0.1.0']],
]) ])
}) })
@@ -144,7 +147,7 @@ describe('挂在卡片子行上的子块', () => {
describe('changeStops', () => { describe('changeStops', () => {
it('新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后;修改块只算自己那几行', () => { 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([ expect(stops.map(stop => [stop.id, stop.change, stop.from, stop.to, stop.ancestors])).toEqual([
['removed-1', 'del', 3, 3, ['0']], ['removed-1', 'del', 3, 3, ['0']],
['0.1', 'mod', 2, 2, ['0']], ['0.1', 'mod', 2, 2, ['0']],
@@ -154,7 +157,7 @@ describe('changeStops', () => {
}) })
it('被筛掉的块和它下面的不算', () => { 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'])
}) })
}) })
+12 -9
View File
@@ -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 { inlineText } from './segments'
import { unfoldToShow } from './viewState' 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 const text = 'properties' in block
? block.properties[0] && ('markdown' in block.properties[0].value ? block.properties[0].value.markdown : inlineText(block.properties[0].value)) ? 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) : 'markdown' in block.translation ? block.translation.markdown : inlineText(block.translation)
@@ -140,8 +141,8 @@ export interface ChangeStop {
ancestors: string[] ancestors: string[]
} }
/** 页面上的各处改动(见 ChangeStop);hidden 里的块(被筛掉的)连同子孙不算。 */ /** 页面上的各处改动(见 ChangeStop,行号是文件里的,按 placement 换算);hidden 里的块(被筛掉的)连同子孙不算。 */
export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden: ReadonlySet<string>): ChangeStop[] { export function changeStops(blocks: readonly BlockView[], placement: Placement, tree: DiffTree, hidden: ReadonlySet<string>): ChangeStop[] {
const stops: ChangeStop[] = [] const stops: ChangeStop[] = []
/** 一层块:owner 是父块 id(顶层 ''),ancestorsOf 给出要让某个子块看得见得展开的,removedAncestors 是这一层删掉的块要展开的 */ /** 一层块:owner 是父块 id(顶层 ''),ancestorsOf 给出要让某个子块看得见得展开的,removedAncestors 是这一层删掉的块要展开的 */
const visit = (list: readonly BlockView[], owner: string, ancestorsOf: (block: BlockView) => string[], removedAncestors: string[]): void => { 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) { for (const block of list) {
const ancestors = ancestorsOf(block) const ancestors = ancestorsOf(block)
const change = hidden.has(block.id) ? undefined : tree.changes.get(block.id) const change = hidden.has(block.id) ? undefined : tree.changes.get(block.id)
if (change?.change === 'add' && change.outermost) const place = placement.of(block.id)
stops.push({ id: block.id, change: 'add', from: block.startLine, to: block.endLine, ancestors }) if (place && change?.change === 'add' && change.outermost)
if (change?.change === 'mod' && change.own) { stops.push({ id: block.id, change: 'add', from: place.line, to: place.endLine, ancestors })
const own = block.children[0] ? Math.max(block.startLine, block.children[0].startLine - 1) : block.endLine if (place && change?.change === 'mod' && change.own) {
stops.push({ id: block.id, change: 'mod', from: block.startLine, to: own, ancestors }) 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') if (change?.change === 'mod')
visit(block.children, block.id, child => [...ancestors, ...unfoldToShow(block.id, child)], [...ancestors, block.id]) visit(block.children, block.id, child => [...ancestors, ...unfoldToShow(block.id, child)], [...ancestors, block.id])
@@ -3,17 +3,21 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { nameMap, sameShownName, shownName } from './displayNames' import { nameMap, sameShownName, shownName } from './displayNames'
import { OccurrenceHighlight } from './occurrences' import { OccurrenceHighlight } from './occurrences'
import { SegmentText, TextMarksContext } from './SegmentText' import { BlockIdContext, SegmentText, TextMarksContext } from './SegmentText'
import { staticStore } from './store' import { staticStore } from './store'
const CURRENT_USER: ShownName = { offset: 40, length: 12, name: '当前用户', definition: true } const CURRENT_USER: ShownName = { offset: 40, length: 12, name: '当前用户', definition: true }
const USE: ShownName = { offset: 90, length: 12, name: '当前用户' } const USE: ShownName = { offset: 90, length: 12, name: '当前用户' }
const ADMIN: ShownName = { offset: 130, length: 5, 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( return renderToStaticMarkup(
<TextMarksContext value={staticStore({ names: nameMap(names) })}> <TextMarksContext value={staticStore({ names: nameMap(names), placement })}>
<BlockIdContext value="b">
<SegmentText segments={segments} /> <SegmentText segments={segments} />
</BlockIdContext>
</TextMarksContext>, </TextMarksContext>,
) )
} }
@@ -29,10 +33,18 @@ describe('页面上的中文名', () => {
it('只认下标和长度都对得上的名字:没下标的译词、长度不一样的(名字改长了)不换', () => { it('只认下标和长度都对得上的名字:没下标的译词、长度不一样的(名字改长了)不换', () => {
const names = nameMap([USE]) const names = nameMap([USE])
expect(shownName({ text: 'current_user', role: 'variable', offset: 90 }, names)).toBe(USE) expect(shownName({ text: 'current_user', role: 'variable', offset: 90 }, 0, names)).toBe(USE)
expect(shownName({ text: 'current_userx', role: 'variable', offset: 90 }, names)).toBeUndefined() expect(shownName({ text: 'current_userx', role: 'variable', offset: 90 }, 0, names)).toBeUndefined()
expect(shownName({ text: 'current_user', role: 'variable' }, names)).toBeUndefined() expect(shownName({ text: 'current_user', role: 'variable' }, 0, names)).toBeUndefined()
expect(shownName({ text: 'current_user', role: 'variable', offset: 91 }, 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('<span class="cr-role-variable" data-offset="10" data-name="当前用户" data-tooltip="原名 current_user">当前用户</span>')
}) })
it('用到的地方显示中文名,悬停写「原名 x」,下标照旧(⌘ 点击跳到定义、单击高亮都认它)', () => { it('用到的地方显示中文名,悬停写「原名 x」,下标照旧(⌘ 点击跳到定义、单击高亮都认它)', () => {
@@ -7,9 +7,9 @@ export function nameMap(names: readonly ShownName[]): NameMap {
return new Map(names.map(name => [name.offset, name])) return new Map(names.map(name => [name.offset, name]))
} }
/** 这段名字显示成什么中文名:照抄自源码、下标和长度都对得上才算。 */ /** 这段名字显示成什么中文名:照抄自源码、下标和长度都对得上才算。段里的下标从所在那块的开头算,那块的开头在文件里的下标是 base。 */
export function shownName(segment: Segment, names: NameMap): ShownName | undefined { export function shownName(segment: Segment, base: number, names: NameMap): ShownName | undefined {
const shown = segment.offset === undefined ? undefined : names.get(segment.offset) const shown = segment.offset === undefined ? undefined : names.get(base + segment.offset)
return shown?.length === segment.text.length ? shown : undefined return shown?.length === segment.text.length ? shown : undefined
} }
@@ -1,17 +1,20 @@
import type { WrittenBlock } from './testBlocks'
import type { BlockView } from './types' import type { BlockView } from './types'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { coveredByEditing, startEditing } from './editing' import { coveredByEditing, startEditing } from './editing'
import { placementOf } from './placement'
import { revealPlan, uncoveredPlan } from './reveal' import { revealPlan, uncoveredPlan } from './reveal'
import { placed } from './testBlocks'
function block(id: string, kind: string, [startLine, endLine]: [number, number], children: BlockView[] = []): BlockView { function block(id: string, kind: string, lines: [number, number], children: WrittenBlock[] = []): WrittenBlock {
return { id, kind, startLine, endLine, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties: [] } 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)是整个类(2–12 行),两个字段中间写着的方法(1.0)和它的主体(1.0.0)、
* 类末尾的方法(1.1)都在这段里;普通的类(2)同样盖住它的方法(2.0);`} else {` 写在一行时 if(3)和 else(4)共用第 20 行。 * 类末尾的方法(1.1)都在这段里;普通的类(2)同样盖住它的方法(2.0);`} else {` 写在一行时 if(3)和 else(4)共用第 20 行。
*/ */
const BLOCKS = [ const BLOCKS = placed([
block('0', 'import', [0, 0]), block('0', 'import', [0, 0]),
block('1', 'sqlalchemy.table', [2, 12], [ block('1', 'sqlalchemy.table', [2, 12], [
block('1.0', 'function', [5, 6], [block('1.0.0', 'return', [6, 6])]), 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('2', 'class', [14, 16], [block('2.0', 'function', [15, 16])]),
block('3', 'if', [18, 20], [block('3.0', 'return', [19, 19])]), block('3', 'if', [18, 20], [block('3.0', 'return', [19, 19])]),
block('4', 'else', [20, 22], [block('4.0', 'return', [21, 21])]), block('4', 'else', [20, 22], [block('4.0', 'return', [21, 21])]),
] ])
const PLACEMENT = placementOf(BLOCKS)
describe('同一行不会同时在两处能改', () => { describe('同一行不会同时在两处能改', () => {
it('块编辑时它的子孙都不画(代码都在它的编辑框里),跳到它们时改闪它', () => { it('块编辑时它的子孙都不画(代码都在它的编辑框里),跳到它们时改闪它', () => {
@@ -38,29 +42,31 @@ describe('同一行不会同时在两处能改', () => {
}) })
it('点「编辑」:编辑范围和它重叠的别的块(祖先、子孙、共用一行的兄弟块)退出编辑,不重叠的留着', () => { it('点「编辑」:编辑范围和它重叠的别的块(祖先、子孙、共用一行的兄弟块)退出编辑,不重叠的留着', () => {
expect(startEditing(BLOCKS, ['1.0', '1.1', '2'], '1')).toEqual(['2', '1']) expect(startEditing(PLACEMENT, ['1.0', '1.1', '2'], '1')).toEqual(['2', '1'])
expect(startEditing(BLOCKS, ['1', '2'], '1.0')).toEqual(['2', '1.0']) expect(startEditing(PLACEMENT, ['1', '2'], '1.0')).toEqual(['2', '1.0'])
expect(startEditing(BLOCKS, ['2'], '2.0')).toEqual(['2.0']) expect(startEditing(PLACEMENT, ['2'], '2.0')).toEqual(['2.0'])
expect(startEditing(BLOCKS, ['0', '3'], '4')).toEqual(['0', '4']) expect(startEditing(PLACEMENT, ['0', '3'], '4')).toEqual(['0', '4'])
}) })
it('任何时候正在编辑、画出来的几块,编辑范围两两不重叠', () => { it('任何时候正在编辑、画出来的几块,编辑范围两两不重叠', () => {
const all = (list: BlockView[]): BlockView[] => list.flatMap(item => [item, ...all(item.children)]) const all = (list: BlockView[]): BlockView[] => list.flatMap(item => [item, ...all(item.children)])
let editing: string[] = [] let editing: string[] = []
for (const id of ['1.0', '1.0.0', '2', '2.0', '1', '3.0', '3', '4', '1.1']) 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 covered = coveredByEditing(BLOCKS, new Set(editing))
const shown = all(BLOCKS).filter(item => editing.includes(item.id) && !covered.has(item.id)) const shown = all(BLOCKS).filter(item => editing.includes(item.id) && !covered.has(item.id))
for (const a of shown) { for (const a of shown) {
for (const b of shown.filter(other => other !== a)) for (const b of shown.filter(other => other !== a)) {
expect(a.startLine > b.endLine || b.startLine > a.endLine, `${a.id} / ${b.id}`).toBe(true) 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('跳到定义的目标被盖住:闪盖住它的那块,展开那块的祖先', () => { it('跳到定义的目标被盖住:闪盖住它的那块,展开那块的祖先', () => {
const covered = coveredByEditing(BLOCKS, new Set(['1'])) const covered = coveredByEditing(BLOCKS, new Set(['1']))
expect(uncoveredPlan({ id: '1.0.0', expand: ['1', '1.0'] }, covered)).toEqual({ id: '1', expand: [] }) 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) expect(plain && uncoveredPlan(plain, covered)).toEqual(plain)
}) })
}) })
+8 -17
View File
@@ -1,3 +1,4 @@
import type { Place, Placement } from './placement'
import type { BlockView } from './types' import type { BlockView } from './types'
/** /**
@@ -6,10 +7,8 @@ import type { BlockView } from './types'
* ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑。 * ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑。
*/ */
type Lines = Pick<BlockView, 'startLine' | 'endLine'> function overlaps(a: Place, b: Place): boolean {
return a.line <= b.endLine && b.line <= a.endLine
function overlaps(a: Lines, b: Lines): boolean {
return a.startLine <= b.endLine && b.startLine <= a.endLine
} }
/** 正在编辑的块的子孙不画 → 盖住它的那块(页面上画着的、正在编辑的祖先):跳到定义落在被盖住的块上时改闪它。 */ /** 正在编辑的块的子孙不画 → 盖住它的那块(页面上画着的、正在编辑的祖先):跳到定义落在被盖住的块上时改闪它。 */
@@ -33,20 +32,12 @@ export function coveredByEditing(blocks: readonly BlockView[], editing: Readonly
return covered return covered
} }
function* allBlocks(blocks: readonly BlockView[]): Generator<BlockView> { /** 点了 id 这块的「编辑」之后正在编辑的块:加上它,编辑范围(块在文件里的首尾行,见 placement)和它重叠的别的块退出编辑。 */
for (const block of blocks) { export function startEditing(placement: Placement, editing: readonly string[], id: string): string[] {
yield block const target = placement.of(id)
yield* allBlocks(block.children)
}
}
/** 点了 id 这块的「编辑」之后正在编辑的块:加上它,编辑范围和它重叠的别的块退出编辑。 */
export function startEditing(blocks: readonly BlockView[], editing: readonly string[], id: string): string[] {
const byId = new Map([...allBlocks(blocks)].map(block => [block.id, block]))
const target = byId.get(id)
const others = editing.filter((other) => { const others = editing.filter((other) => {
const block = byId.get(other) const place = placement.of(other)
return other !== id && !(target && block && overlaps(target, block)) return other !== id && !(target && place && overlaps(target, place))
}) })
return [...others, id] return [...others, id]
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export interface Facet {
* 块被不被筛掉:它有的属性里有一个的值被去掉了就筛掉。也就是同一属性里勾着的值满足一个就行、不同属性要同时满足; * 块被不被筛掉:它有的属性里有一个的值被去掉了就筛掉。也就是同一属性里勾着的值满足一个就行、不同属性要同时满足;
* 块没有的属性(条件、返回块没有访问级别)不受那个属性的筛选影响。 * 块没有的属性(条件、返回块没有访问级别)不受那个属性的筛选影响。
*/ */
export function excludedBy(block: BlockView, excluded: Excluded): boolean { export function excludedBy(block: Pick<BlockView, 'attributes'>, excluded: Excluded): boolean {
return Object.entries(block.attributes).some(([attribute, value]) => excluded[attribute]?.includes(value) ?? false) return Object.entries(block.attributes).some(([attribute, value]) => excluded[attribute]?.includes(value) ?? false)
} }
@@ -1,14 +1,14 @@
import type { BlockView } from './types' import type { BlockView } from './types'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { estimatedHeight } from './itemHeight' import { estimatedHeight } from './itemHeight'
import { NO_SPAN } from './testBlocks'
function block(id: string, kind: string, extra: Partial<BlockView> = {}): BlockView { function block(id: string, kind: string, extra: Partial<BlockView> = {}): BlockView {
return { return {
id, id,
anchor: id, anchor: id,
kind, kind,
startLine: 0, span: NO_SPAN,
endLine: 0,
attributes: { kind }, attributes: { kind },
concepts: [], concepts: [],
children: [], children: [],
@@ -36,14 +36,14 @@ function find(occ: OccurrenceHighlight, offset: number, text: string): void {
describe('occurrences:哪些段画高亮', () => { describe('occurrences:哪些段画高亮', () => {
it('段在源码里的范围和一处出现重叠就画,边界相接不算,没有下标的不画', () => { it('段在源码里的范围和一处出现重叠就画,边界相接不算,没有下标的不画', () => {
const occurrences = [at(10, 18)] const occurrences = [at(10, 18)]
expect(segmentOccurrence(name('ALPHABET', 10), occurrences)).toBe('normal') expect(segmentOccurrence(name('ALPHABET', 10), 0, occurrences)).toBe('normal')
expect(segmentOccurrence(name('x', 2), occurrences)).toBeUndefined() expect(segmentOccurrence(name('x', 2), 0, occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 8), occurrences)).toBeUndefined() expect(segmentOccurrence(name('ab', 8), 0, occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 18), occurrences)).toBeUndefined() expect(segmentOccurrence(name('ab', 18), 0, occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 17), occurrences)).toBe('normal') expect(segmentOccurrence(name('ab', 17), 0, occurrences)).toBe('normal')
expect(segmentOccurrence({ text: '常量', role: 'storage' }, occurrences)).toBeUndefined() 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('出现很多、乱序、有长有短还互相重叠时,结果和逐处比一样', () => { it('出现很多、乱序、有长有短还互相重叠时,结果和逐处比一样', () => {
@@ -63,7 +63,7 @@ describe('occurrences:哪些段画高亮', () => {
}) })
for (let offset = 0; offset < 2020; offset += 3) { for (let offset = 0; offset < 2020; offset += 3) {
const segment = name('x'.repeat(1 + (offset % 7)), offset) 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('write')).toBe('strong')
expect(occurrenceStyle('read')).toBe('normal') expect(occurrenceStyle('read')).toBe('normal')
expect(occurrenceStyle('text')).toBe('normal') expect(occurrenceStyle('text')).toBe('normal')
expect(segmentOccurrence(name('a', 0), [at(0, 1, 'text')])).toBe('normal') expect(segmentOccurrence(name('a', 0), 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, 'read'), at(0, 1, 'write')])).toBe('strong')
}) })
it('讲别处名字的段(refers,写在字符串里的参数名)按那个名字算:那个名字的出现盖住它就画、能是当前那一处,自己照抄的位置不算', () => { it('讲别处名字的段(refers,写在字符串里的参数名)按那个名字算:那个名字的出现盖住它就画、能是当前那一处,自己照抄的位置不算', () => {
const param: Segment = { text: 'user_id', role: 'variable', offset: 40, refers: { startIndex: 100, endIndex: 107 } } const param: Segment = { text: 'user_id', role: 'variable', offset: 40, refers: { startIndex: 100, endIndex: 107 } }
expect(sourceOf(param)).toEqual({ start: 100, end: 107 }) expect(sourceOf(param, 0)).toEqual({ start: 100, end: 107 })
expect(segmentOccurrence(param, [at(100, 107, 'write')])).toBe('strong') expect(segmentOccurrence(param, 0, [at(100, 107, 'write')])).toBe('strong')
expect(segmentOccurrence(param, [at(40, 47)])).toBeUndefined() expect(segmentOccurrence(param, 0, [at(40, 47)])).toBeUndefined()
expect(isCurrent(param, { name: 'user_id', occurrences: [at(100, 107)], current: 0 })).toBe(true) expect(isCurrent(param, 0, { name: 'user_id', occurrences: [at(100, 107)], current: 0 })).toBe(true)
expect(sourceOf(name('x', 3))).toEqual({ start: 3, end: 4 }) expect(sourceOf(name('x', 3), 0)).toEqual({ start: 3, end: 4 })
expect(sourceOf({ text: '常量', role: 'storage' })).toBeUndefined() 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 () => { it('真实的 Python 译文和属性表:定义处按写入、用到的地方按读取画;点分模块名每一节各按自己的范围认', async () => {
@@ -98,7 +108,7 @@ describe('occurrences:哪些段画高亮', () => {
const py = createPythonAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR)
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator)) 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 highlighted = (occurrences: Occurrence[]): string[] => allSegments(views).flatMap((segment) => {
const style = segmentOccurrence(segment, occurrences) const style = segmentOccurrence(segment, 0, occurrences)
return style ? [`${segment.text}:${style}`] : [] return style ? [`${segment.text}:${style}`] : []
}) })
const range = (needle: string, kind: Occurrence['kind'], from = 0): Occurrence => { const range = (needle: string, kind: Occurrence['kind'], from = 0): Occurrence => {
@@ -318,10 +328,10 @@ describe('occurrences:用键盘在各处出现之间跳', () => {
it('当前那一处:和它重叠的段描边,别的出现、没下标的段不描', () => { it('当前那一处:和它重叠的段描边,别的出现、没下标的段不描', () => {
const highlight = { name: 'ALPHABET', occurrences: [at(10, 18), at(40, 48)], current: 1 } 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', 40), 0, highlight)).toBe(true)
expect(isCurrent(name('ALPHABET', 10), highlight)).toBe(false) expect(isCurrent(name('ALPHABET', 10), 0, highlight)).toBe(false)
expect(isCurrent({ text: 'ALPHABET', role: 'plain' }, highlight)).toBe(false) expect(isCurrent({ text: 'ALPHABET', role: 'plain' }, 0, highlight)).toBe(false)
expect(isCurrent(name('ALPHABET', 40), { ...highlight, current: undefined })).toBe(false) expect(isCurrent(name('ALPHABET', 40), 0, { ...highlight, current: undefined })).toBe(false)
}) })
it('能不能用键盘跳:有高亮、页面有焦点、焦点不在输入框里;第一次都告诉扩展端,之后变了才告诉', () => { it('能不能用键盘跳:有高亮、页面有焦点、焦点不在输入框里;第一次都告诉扩展端,之后变了才告诉', () => {
+17 -16
View File
@@ -8,11 +8,11 @@ export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle {
} }
/** /**
* 一段文字画不画高亮、画哪种:它讲的名字(sourceOf)和哪处出现重叠就画, * 一段文字画不画高亮、画哪种:它讲的名字(sourceOf,所在那块的开头在文件里的下标是 base)和哪处出现重叠就画,
* 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。 * 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。
*/ */
export function segmentOccurrence(segment: Segment, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined { export function segmentOccurrence(segment: Segment, base: number, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined {
const source = sourceOf(segment) const source = sourceOf(segment, base)
if (!source) if (!source)
return undefined return undefined
const { sorted, longest } = occurrenceIndex(occurrences) const { sorted, longest } = occurrenceIndex(occurrences)
@@ -66,23 +66,24 @@ function firstStartAtOrAfter(sorted: readonly Occurrence[], at: number): number
/** /**
* 段讲的是源码里哪个名字,[start, end):讲别处名字的段(refers,写在字符串里的参数名)是那个名字,照抄自源码的段是它自己占的 * 段讲的是源码里哪个名字,[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) if (segment.refers)
return { start: segment.refers.startIndex, end: segment.refers.endIndex } return { start: base + segment.refers.startIndex, end: base + segment.refers.endIndex }
return segment.offset === undefined ? undefined : { start: segment.offset, end: segment.offset + segment.text.length } return segment.offset === undefined ? undefined : { start: base + segment.offset, end: base + segment.offset + segment.text.length }
} }
/** 段讲的名字(sourceOf)和这处出现重叠;没有下标的段不算。 */ /** 段讲的名字(sourceOf,所在那块的开头是 base)和这处出现重叠;没有下标的段不算。 */
export function overlaps(segment: Segment, occurrence: Occurrence): boolean { export function overlaps(segment: Segment, base: number, occurrence: Occurrence): boolean {
const source = sourceOf(segment) const source = sourceOf(segment, base)
return source !== undefined && occurrence.start < source.end && source.start < occurrence.end return source !== undefined && occurrence.start < source.end && source.start < occurrence.end
} }
/** 文字里(连框里)有段和这处出现重叠。 */ /** 文字里(连框里)有段和这处出现重叠;文字所在那块的开头是 base。 */
export function showsOccurrence(pieces: readonly Inline[], occurrence: Occurrence): boolean { export function showsOccurrence(pieces: readonly Inline[], base: number, occurrence: Occurrence): boolean {
return pieces.some(piece => 'role' in piece ? overlaps(piece, occurrence) : showsOccurrence(piece.segments, occurrence)) return pieces.some(piece => 'role' in piece ? overlaps(piece, base, occurrence) : showsOccurrence(piece.segments, base, occurrence))
} }
/** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */ /** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */
@@ -126,10 +127,10 @@ export interface Highlight {
current?: number current?: number
} }
/** 段是不是当前那一处出现(点中的、用键盘跳到的)。 */ /** 段是不是当前那一处出现(点中的、用键盘跳到的);段所在那块的开头是 base。 */
export function isCurrent(segment: Segment, highlight: Highlight): boolean { export function isCurrent(segment: Segment, base: number, highlight: Highlight): boolean {
const current = highlight.current === undefined ? undefined : highlight.occurrences[highlight.current] 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 处」;看不见的也算在总数里。 */ /** 筛选栏上名字后面写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import type { Placement } from './placement'
import type { BlockBox } from './scrollPosition' import type { BlockBox } from './scrollPosition'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { PageLayout } from './pageLayout' 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)) return div('cr-level', owner === undefined ? {} : { 'data-owner': owner }, div('guide'), div('column', {}, ...children))
} }
/** 各块在文件里的起始行(页面上不记行号,按块 id 从文档里各块的位置取) */
const LINES: Record<string, number> = {}
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 { 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') const root = document.createElement('div')
root.append(level( root.append(level(
undefined, 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', {}, 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', {}, 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('2', 40, 1510, 2000)), 1510, 2000),
)) ))
document.body.append(root) 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 })) 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('滚动位置、包含某一行的块:和把全部卡片量一遍算出来的一样', () => { it('滚动位置、包含某一行的块:和把全部卡片量一遍算出来的一样', () => {
const { root, cards } = page() const { root, cards } = page()
const layout = new PageLayout() 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]) { for (const y of [0, 50, 100, 310, 950, 1200, 1999, 2500]) {
const found = layout.anchorAt(y) const found = layout.anchorAt(y)
expect(found && { line: found.line, offset: found.offset }, `y ${y}`).toEqual((({ line, offset }) => ({ line, offset }))(anchorAt(cards, 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('钉住的卡片:只算和顶上那一段相交的顶层块,在编辑的不钉;外层卡片的下沿算到它的子层', () => { it('钉住的卡片:只算和顶上那一段相交的顶层块,在编辑的不钉;外层卡片的下沿算到它的子层', () => {
const { root } = page() const { root } = page()
const layout = new PageLayout() const layout = new PageLayout()
layout.attach(root, null) layout.attach(root, null, () => PLACEMENT)
window.scrollTo(0, 0) window.scrollTo(0, 0)
const scopes = layout.stickyScopes(48) const scopes = layout.stickyScopes(48)
expect(scopes.map(scope => [scope.id, scope.depth, scope.top, scope.bottom])).toEqual([['0', 0, 0, 900]]) expect(scopes.map(scope => [scope.id, scope.depth, scope.top, scope.bottom])).toEqual([['0', 0, 0, 900]])
@@ -83,7 +89,7 @@ describe('卡片位置量过就记着', () => {
it('滚动时不再量:同一份内容问几次都只量一次;只量用到的那几段;内容变了才重量', async () => { it('滚动时不再量:同一份内容问几次都只量一次;只量用到的那几段;内容变了才重量', async () => {
const { root } = page() const { root } = page()
const layout = new PageLayout() const layout = new PageLayout()
layout.attach(root, null) layout.attach(root, null, () => PLACEMENT)
layout.anchorAt(0) layout.anchorAt(0)
// 三段顶层块的外框 + 第 0、1 段里的卡片和子层 // 三段顶层块的外框 + 第 0、1 段里的卡片和子层
const first = reads const first = reads
@@ -101,7 +107,7 @@ describe('卡片位置量过就记着', () => {
it('要滚过去的:目标所在的顶层块连同上下几屏之内的先照常排版,过两帧放回去', async () => { it('要滚过去的:目标所在的顶层块连同上下几屏之内的先照常排版,过两帧放回去', async () => {
const { root } = page() const { root } = page()
const layout = new PageLayout() const layout = new PageLayout()
layout.attach(root, null) layout.attach(root, null, () => PLACEMENT)
const items = [...root.querySelectorAll('.cr-item')] const items = [...root.querySelectorAll('.cr-item')]
expect(layout.cardForLine(40)?.line).toBe(40) expect(layout.cardForLine(40)?.line).toBe(40)
expect(items.map(item => item.hasAttribute('data-settle'))).toEqual([true, true, true]) expect(items.map(item => item.hasAttribute('data-settle'))).toEqual([true, true, true])
+26 -11
View File
@@ -1,5 +1,7 @@
import type { Placement } from './placement'
import type { BlockBox, ScrollAnchor } from './scrollPosition' import type { BlockBox, ScrollAnchor } from './scrollPosition'
import type { StickyScope } from './stickyScroll' import type { StickyScope } from './stickyScroll'
import { NO_PLACEMENT } from './placement'
import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition' import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition'
/** 一张卡片量出来的位置(文档坐标):BlockBox 之外,块 id、外面套着几张卡片、连同画出来的子块的下沿、左右边,在不在编辑。 */ /** 一张卡片量出来的位置(文档坐标):BlockBox 之外,块 id、外面套着几张卡片、连同画出来的子块的下沿、左右边,在不在编辑。 */
@@ -28,8 +30,8 @@ export function depthOf(card: Element): number {
return depth return depth
} }
/** 一段顶层块里的卡片(BlockCard 带 data-line;删掉的块那张没有),按页面先后;下沿算到它名下画出来的子层(带 data-owner)的最后 */ /** 一段顶层块里的卡片(这份文档里的块;删掉的块那张不算,它在文件里没有位置),按页面先后;起始行按 placement 换算,下沿算到它名下画出来的子层(带 data-owner)的最后 */
function measureCards(item: HTMLElement): CardBox[] { function measureCards(item: HTMLElement, placement: Placement): CardBox[] {
const x = window.scrollX const x = window.scrollX
const y = window.scrollY const y = window.scrollY
const bottoms = new Map<string, number>() const bottoms = new Map<string, number>()
@@ -37,13 +39,16 @@ function measureCards(item: HTMLElement): CardBox[] {
const owner = level.dataset.owner ?? '' const owner = level.dataset.owner ?? ''
bottoms.set(owner, Math.max(bottoms.get(owner) ?? Number.NEGATIVE_INFINITY, level.getBoundingClientRect().bottom + y)) bottoms.set(owner, Math.max(bottoms.get(owner) ?? Number.NEGATIVE_INFINITY, level.getBoundingClientRect().bottom + y))
} }
return Array.from(item.querySelectorAll<HTMLElement>('.cr-block[data-line]'), (card) => { return Array.from(item.querySelectorAll<HTMLElement>('.cr-block[data-id]')).flatMap((card) => {
const id = card.dataset.id ?? '' const id = card.dataset.id ?? ''
const place = placement.of(id)
if (!place)
return []
const rect = card.getBoundingClientRect() const rect = card.getBoundingClientRect()
const bottom = rect.bottom + y const bottom = rect.bottom + y
return { return [{
id, id,
line: Number(card.dataset.line), line: place.line,
top: rect.top + y, top: rect.top + y,
bottom, bottom,
scopeBottom: Math.max(bottom, bottoms.get(id) ?? bottom), scopeBottom: Math.max(bottom, bottoms.get(id) ?? bottom),
@@ -51,7 +56,7 @@ function measureCards(item: HTMLElement): CardBox[] {
right: rect.right + x, right: rect.right + x,
depth: depthOf(card), depth: depthOf(card),
editing: card.hasAttribute('data-editing'), editing: card.hasAttribute('data-editing'),
} }]
}) })
} }
@@ -78,10 +83,12 @@ function itemOfLine(items: readonly ItemBox[], line: number): number {
* 页面上卡片的位置和筛选栏的高度,量过就记着,滚动时只在记着的里面按顶层块二分找,不再每帧量全部卡片(量位置会逼浏览器先排版)。 * 页面上卡片的位置和筛选栏的高度,量过就记着,滚动时只在记着的里面按顶层块二分找,不再每帧量全部卡片(量位置会逼浏览器先排版)。
* 顶层的块(.cr-item)先整段量,里面的卡片用到哪段才量哪段(屏幕外的段可能没排版,量它要先排一次)。坐标都是文档坐标(视口坐标 + 滚动位置),滚动不改它。 * 顶层的块(.cr-item)先整段量,里面的卡片用到哪段才量哪段(屏幕外的段可能没排版,量它要先排一次)。坐标都是文档坐标(视口坐标 + 滚动位置),滚动不改它。
* 页面内容的结构变了(MutationObserver)、大小变了(ResizeObserver)、窗口大小变了,或页面重画了(invalidate,同一段脚本里改完马上要量的)才作废,下次用时重量。 * 页面内容的结构变了(MutationObserver)、大小变了(ResizeObserver)、窗口大小变了,或页面重画了(invalidate,同一段脚本里改完马上要量的)才作废,下次用时重量。
* 卡片的起始行按 placement(这一版文档里各块在文件里的位置)换算;换了一版文档页面跟着重画,记着的一并作废。
*/ */
export class PageLayout { export class PageLayout {
private root: HTMLElement | null = null private root: HTMLElement | null = null
private bar: HTMLElement | null = null private bar: HTMLElement | null = null
private placement: () => Placement = () => NO_PLACEMENT
private items: ItemBox[] | undefined private items: ItemBox[] | undefined
private cards = new Map<HTMLElement, CardBox[]>() private cards = new Map<HTMLElement, CardBox[]>()
private barHeight: number | undefined private barHeight: number | undefined
@@ -89,14 +96,15 @@ export class PageLayout {
private settling: HTMLElement[] = [] private settling: HTMLElement[] = []
private release = 0 private release = 0
/** 量 root(块树所在的页面内容)里的卡片,筛选栏是 bar;返回停止观察的函数 */ /** 量 root(块树所在的页面内容)里的卡片,筛选栏是 bar,各块在文件里的位置从 placement 取;返回停止观察的函数 */
attach(root: HTMLElement, bar: HTMLElement | null): () => void { attach(root: HTMLElement, bar: HTMLElement | null, placement: () => Placement): () => void {
this.root = root this.root = root
this.bar = bar this.bar = bar
this.placement = placement
this.invalidate() this.invalidate()
const invalidate = (): void => this.invalidate() const invalidate = (): void => this.invalidate()
const mutations = new MutationObserver(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) const sizes = new ResizeObserver(invalidate)
sizes.observe(root) sizes.observe(root)
if (bar) if (bar)
@@ -109,6 +117,7 @@ export class PageLayout {
if (this.root === root) { if (this.root === root) {
this.root = null this.root = null
this.bar = null this.bar = null
this.placement = () => NO_PLACEMENT
this.invalidate() this.invalidate()
} }
} }
@@ -131,11 +140,17 @@ export class PageLayout {
// 顶层那一层:块树最外面的 .cr-level 的右栏,里面的 .cr-item 一块一段 // 顶层那一层:块树最外面的 .cr-level 的右栏,里面的 .cr-item 一块一段
const column = this.root?.querySelector(':scope > .cr-level > :last-child') const column = this.root?.querySelector(':scope > .cr-level > :last-child')
const y = window.scrollY const y = window.scrollY
const placement = this.placement()
this.items = Array.from(column?.children ?? []).flatMap((el) => { this.items = Array.from(column?.children ?? []).flatMap((el) => {
if (!(el instanceof HTMLElement) || !el.classList.contains('cr-item')) if (!(el instanceof HTMLElement) || !el.classList.contains('cr-item'))
return [] return []
// 这段的起始行是打头那张卡片那块的
const id = el.querySelector<HTMLElement>(':scope > .cr-block')?.dataset.id
const place = id === undefined ? undefined : placement.of(id)
if (!place)
return []
const rect = el.getBoundingClientRect() 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 return this.items
@@ -144,7 +159,7 @@ export class PageLayout {
private cardsOf(item: ItemBox): CardBox[] { private cardsOf(item: ItemBox): CardBox[] {
let cards = this.cards.get(item.el) let cards = this.cards.get(item.el)
if (!cards) { if (!cards) {
cards = measureCards(item.el) cards = measureCards(item.el, this.placement())
this.cards.set(item.el, cards) this.cards.set(item.el, cards)
} }
return cards return cards
+11 -4
View File
@@ -2,11 +2,13 @@ import type { SourceText } from './blockSource'
import type { BlockTreeState } from './BlockTree' import type { BlockTreeState } from './BlockTree'
import type { ChangeStop, DiffTree, FoldedRuns } from './diffView' import type { ChangeStop, DiffTree, FoldedRuns } from './diffView'
import type { FilterView } from './filter' import type { FilterView } from './filter'
import type { Placement } from './placement'
import type { ReadableStore } from './store' import type { ReadableStore } from './store'
import type { BlockView, CallTree, ContractMiss, DocumentDiff, DocumentMessage, Excluded, KindBadge, Reveal, SourceColors } from './types' import type { BlockView, CallTree, ContractMiss, DocumentDiff, DocumentMessage, Excluded, KindBadge, Reveal, SourceColors } from './types'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView' import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
import { coveredByEditing } from './editing' import { coveredByEditing } from './editing'
import { applyFilter, excludedBy } from './filter' import { applyFilter, excludedBy } from './filter'
import { placementOf } from './placement'
import { createStore } from './store' import { createStore } from './store'
import { collapsedIds, foldsByCard } from './viewState' import { collapsedIds, foldsByCard } from './viewState'
@@ -59,6 +61,8 @@ export interface Page extends PageInputs {
covered: ReadonlyMap<string, string> covered: ReadonlyMap<string, string>
/** 「只看改动」折起来的块 */ /** 「只看改动」折起来的块 */
folded: FoldedRuns | undefined folded: FoldedRuns | undefined
/** 各块在文件里的位置(见 placementOf):块里的位置都从块的开头算,和文件里的位置打交道时按它换算 */
placement: Placement
/** 页面上没画出来的块:被筛掉的,和「只看改动」折起来的 */ /** 页面上没画出来的块:被筛掉的,和「只看改动」折起来的 */
unshown: ReadonlySet<string> unshown: ReadonlySet<string>
stops: ChangeStop[] stops: ChangeStop[]
@@ -101,7 +105,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
? foldUnchanged(blocks, changes, block => !filter.hidden.has(block.id) && !covered.has(block.id), openedRuns, keep) ? foldUnchanged(blocks, changes, block => !filter.hidden.has(block.id) && !covered.has(block.id), openedRuns, keep)
: undefined) : undefined)
const unshownOf = memoizeOne((filter: FilterView, folded: FoldedRuns | undefined) => folded ? new Set([...filter.hidden, ...folded.folded]) : filter.hidden) 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<string, ReadonlySet<string>> | undefined let folds: ReadonlyMap<string, ReadonlySet<string>> | undefined
const foldsOf = memoizeOne((collapsed: ReadonlySet<string>) => { const foldsOf = memoizeOne((collapsed: ReadonlySet<string>) => {
folds = foldsByCard(collapsed, folds) folds = foldsByCard(collapsed, folds)
@@ -114,6 +118,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
const treeOf = memoizeOne(( const treeOf = memoizeOne((
version: number, version: number,
sourceText: SourceText, sourceText: SourceText,
placement: Placement,
editing: ReadonlySet<string>, editing: ReadonlySet<string>,
focusId: string | null, focusId: string | null,
counts: ReadonlyMap<string, number>, counts: ReadonlyMap<string, number>,
@@ -125,7 +130,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
changes: DiffTree | undefined, changes: DiffTree | undefined,
folded: FoldedRuns | undefined, folded: FoldedRuns | undefined,
): BlockTreeState => ({ ): BlockTreeState => ({
edit: { version, sourceText, editing, focusId }, edit: { version, sourceText, placement, editing, focusId },
callTree: { counts, ...(openAnchor !== undefined && { openAnchor }) }, callTree: { counts, ...(openAnchor !== undefined && { openAnchor }) },
collapsed, collapsed,
folds, folds,
@@ -139,6 +144,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
const derive = (inputs: PageInputs): Page => { const derive = (inputs: PageInputs): Page => {
const { doc } = inputs const { doc } = inputs
const blocks = doc?.blocks ?? NO_BLOCKS const blocks = doc?.blocks ?? NO_BLOCKS
const placement = placementOf(blocks)
const version = doc?.version ?? 0 const version = doc?.version ?? 0
const changes = doc ? changesOf(blocks, version, doc.error !== undefined, inputs.diff, inputs.excluded) : undefined const changes = doc ? changesOf(blocks, version, doc.error !== undefined, inputs.diff, inputs.excluded) : undefined
const collapsed = collapsedOf(blocks, inputs.folds, changes) const collapsed = collapsedOf(blocks, inputs.folds, changes)
@@ -154,9 +160,10 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
filter, filter,
covered, covered,
folded, folded,
placement,
unshown: unshownOf(filter, folded), unshown: unshownOf(filter, folded),
stops: stopsOf(blocks, changes, filter), stops: stopsOf(blocks, placement, 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), 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),
} }
} }
@@ -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<HostBlockView[]> {
return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator))
}
function flatten<T extends { children: T[] }>(list: T[]): T[] {
return list.flatMap(block => [block, ...flatten(block.children)])
}
function byId(blocks: BlockView[]): Map<string, BlockView> {
return new Map(flatten(blocks).map(block => [block.id, block]))
}
/** 一块自己的样子(不连子块):子块变了不算它变了 */
function own({ children: _, ...rest }: BlockView): Omit<BlockView, 'children'> {
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 })
}
})
})
@@ -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<readonly BlockView[], Placement>()
/** blocks 这份块树里各块在文件里的位置(顶层第一块从文件开头算);同一份块树只算一次。 */
export function placementOf(blocks: readonly BlockView[]): Placement {
let placement = placements.get(blocks)
if (!placement) {
const places = new Map<string, Place>()
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<HTMLElement>(OWNER)?.dataset.id
const place = owner === undefined ? undefined : placement.of(owner)
return name.dataset.offset === undefined || !place ? undefined : place.offset + Number(name.dataset.offset)
}
@@ -7,8 +7,8 @@ import type { ViewState, ViewStateStore } from './viewState'
import { act } from 'react' import { act } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { peekBlock } from '../../../src/codeReader/editor/definitionPeek' import { pagePeekBlock, peekBlock } from '../../../src/codeReader/editor/definitionPeek'
import { documentMessage } from '../../../src/codeReader/editor/documentMessage' import { documentMessage, pageDocument } from '../../../src/codeReader/editor/documentMessage'
import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { CodeReaderRegistry } from '../../../src/codeReader/registry' import { CodeReaderRegistry } from '../../../src/codeReader/registry'
import { runtime, WASM_DIR } from '../../../src/codeReader/testSupport' import { runtime, WASM_DIR } from '../../../src/codeReader/testSupport'
@@ -77,7 +77,7 @@ async function read(source: string, version: number): Promise<Awaited<ReturnType
/** 扩展端发给页面的那份文档 */ /** 扩展端发给页面的那份文档 */
async function documentOf(source: string, version: number): Promise<ExtensionToWebview> { async function documentOf(source: string, version: number): Promise<ExtensionToWebview> {
return { ...await read(source, version), names: [] } as ExtensionToWebview return { ...pageDocument(await read(source, version)), names: [] } as ExtensionToWebview
} }
/** 扩展端发给页面的定义卡片:source 里 needle 开头那个名字所在的块,名字显示成 name */ /** 扩展端发给页面的定义卡片:source 里 needle 开头那个名字所在的块,名字显示成 name */
@@ -85,7 +85,7 @@ async function peekOf(source: string, needle: string, name: string): Promise<Ext
const offset = source.indexOf(needle) const offset = source.indexOf(needle)
const line = source.slice(0, offset).split('\n').length - 1 const line = source.slice(0, offset).split('\n').length - 1
const { blocks } = await read(source, 1) const { blocks } = await read(source, 1)
return { path: 'other.py', line, block: peekBlock(blocks, offset, line)!, names: [{ offset, length: needle.length, name }] } return { path: 'other.py', line, block: pagePeekBlock(peekBlock(blocks, offset, line)!, source), names: [{ offset, length: needle.length, name }] }
} }
/** source 里第 nth 个 needle 的下标、所在的行 */ /** source 里第 nth 个 needle 的下标、所在的行 */
+108 -85
View File
@@ -1,15 +1,20 @@
import type { BlockView as HostBlockView } from '../../../src/codeReader/blockView'
import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract' import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract'
import type { RevealPlan } from './reveal' import type { RevealPlan } from './reveal'
import type { BlockKind, BlockView, Inline, Occurrence, PropertyItem, PropertyRow, Segment } from './types' import type { WrittenBlock } from './testBlocks'
import type { BlockKind, Inline, Occurrence, PropertyItem, PropertyRow, Segment } from './types'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView' import { toBlockViews } from '../../../src/codeReader/blockView'
import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks'
import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript' import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript'
import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy' import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh' import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh'
import { placementOf } from './placement'
import { occurrencePlan, revealPlan, scrollTopToShow } from './reveal' import { occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
import { placed } from './testBlocks'
import { collapsedIds, propertyItemId, propertyRowId } from './viewState' import { collapsedIds, propertyItemId, propertyRowId } from './viewState'
function name(text: string, offset: number): Segment { function name(text: string, offset: number): Segment {
@@ -20,12 +25,30 @@ function word(text: string): Segment {
return { text, role: 'keyword' } return { text, role: 'keyword' }
} }
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView { function textBlock(id: string, kind: BlockKind, line: number, translation: Inline[], children: WrittenBlock[] = []): WrittenBlock {
return { id, kind, startLine, endLine: startLine, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation } return { id, anchor: id, kind, lines: [line, line], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation }
} }
function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView { function factBlock(id: string, kind: BlockKind, line: number, properties: PropertyRow[], children: WrittenBlock[] = []): WrittenBlock {
return { id, kind, startLine, endLine: startLine, attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties } return { id, anchor: id, kind, lines: [line, line], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties }
}
/** 手写的块(下标就是文件里的,见 WrittenBlock)排成扩展端发来的样子,再按文件里的行、下标找跳到哪 */
function revealIn(blocks: WrittenBlock[], line: number, definition: number): RevealPlan | undefined {
const page = placed(blocks)
return revealPlan(page, placementOf(page), line, definition)
}
/** 同 revealIn:用键盘跳到这一处出现时滚到哪 */
function occurrenceIn(blocks: WrittenBlock[], occurrence: Occurrence, skip: (id: string) => 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) * 4 return get_db(path)
* 8 LIMIT = 3 * 8 LIMIT = 3
*/ */
const BLOCKS: BlockView[] = [ const BLOCKS: WrittenBlock[] = [
factBlock('0', 'import', 0, [ factBlock('0', 'import', 0, [
{ label: '来源', value: [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)] }, { label: '来源', value: [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)] },
{ label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 34)]] }] }, { 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' }) const at = (start: number, end: number): Occurrence => ({ start, end, kind: 'read' })
it('在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { 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('在属性行展开的子行里(导入项、参数):连那一行一起展开;在属性行的值里不用展开', () => { it('在属性行展开的子行里(导入项、参数):连那一行一起展开;在属性行的值里不用展开', () => {
expect(occurrencePlan(BLOCKS, at(34, 40), nowhere)).toEqual({ id: '0', expand: ['0/p1'] }) expect(occurrenceIn(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(occurrenceIn(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(130, 135), nowhere)).toEqual({ id: '2', expand: [] })
}) })
it('和段重叠就算(点分模块名的一节、比名字长的段);页面上哪儿都没有时没有', () => { it('和段重叠就算(点分模块名的一节、比名字长的段);页面上哪儿都没有时没有', () => {
expect(occurrencePlan(BLOCKS, at(18, 20), nowhere)).toEqual({ id: '0', expand: [] }) expect(occurrenceIn(BLOCKS, at(18, 20), nowhere)).toEqual({ id: '0', expand: [] })
expect(occurrencePlan(BLOCKS, at(62, 70), nowhere)).toBeUndefined() expect(occurrenceIn(BLOCKS, at(62, 70), nowhere)).toBeUndefined()
}) })
it('被筛掉的、正在编辑的、被盖住的块里的不算;只跳过那一块,它下面画出来的子块照常算', () => { it('被筛掉的、正在编辑的、被盖住的块里的不算;只跳过那一块,它下面画出来的子块照常算', () => {
const hidden = new Set(['1.0', '1.0.0']) const hidden = new Set(['1.0', '1.0.0'])
expect(occurrencePlan(BLOCKS, at(102, 108), id => hidden.has(id))).toBeUndefined() expect(occurrenceIn(BLOCKS, at(102, 108), id => hidden.has(id))).toBeUndefined()
expect(occurrencePlan(BLOCKS, at(51, 55), id => id === '1')).toBeUndefined() expect(occurrenceIn(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 => id === '1')).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
}) })
it('同一处在页面上显示几次:取页面上的第一处;Markdown 里的不算', () => { it('同一处在页面上显示几次:取页面上的第一处;Markdown 里的不算', () => {
const blocks: BlockView[] = [ const blocks: WrittenBlock[] = [
factBlock('0', 'function', 0, [{ label: '说明', value: { markdown: '见 `get_db`' } }], [ factBlock('0', 'function', 0, [{ label: '说明', value: { markdown: '见 `get_db`' } }], [
textBlock('0.0', 'return', 1, [word('返回'), name('get_db', 30)]), 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)]] }] }]), 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(occurrenceIn(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), id => id === '0.0')).toEqual({ id: '1', expand: ['1/p0'] })
}) })
}) })
describe('跳到定义:滚到哪块、展开哪些', () => { describe('跳到定义:滚到哪块、展开哪些', () => {
it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { 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('名字在一行译文下面那张表里(跨几行写的调用的实参):表总是展开的,目标是那块,不用展开什么', () => { it('名字在一行译文下面那张表里(跨几行写的调用的实参):表总是展开的,目标是那块,不用展开什么', () => {
const call: BlockView = { const call: WrittenBlock = {
...textBlock('0.0', 'return', 1, [word('返回'), word('新建'), name('UserDetailResponse', 10)]), ...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)]] }] }, 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])] const blocks = [textBlock('0', 'if', 0, [word('如果'), name('user', 3)], [call])]
expect(revealPlan(blocks, 9, 48)).toEqual({ id: '0.0', expand: ['0'] }) expect(revealIn(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(occurrenceIn(blocks, { start: 40, end: 42, kind: 'read' }, () => false)).toEqual({ id: '0.0', expand: ['0'] })
}) })
it('名字在值那一格里套着的实参表里(套了几层都算):一行译文下面的不用展开,变量初始值那一行下面的展开那一行', () => { it('名字在值那一格里套着的实参表里(套了几层都算):一行译文下面的不用展开,变量初始值那一行下面的展开那一行', () => {
const leaf = { columns: ['参数', '值'], items: [{ cells: [[name('mp_app_secret', 70)], [name('secret', 90)]] }] } 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 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 }, { ...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 }]), factBlock('1', 'variable', 5, [{ label: '初始值', value: [word('新建'), name('Diagnostic', 110)], columns: ['参数', '值'], items: nested.items }]),
] ]
expect(revealPlan(blocks, 9, 90)).toEqual({ id: '0', expand: [] }) expect(revealIn(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(occurrenceIn(blocks, { start: 70, end: 83, kind: 'read' }, id => id === '0')).toEqual({ id: '1', expand: ['1/p0'] })
}) })
it('名字在拆开的列表里:列表项下面的实参表总是展开的;实参表格子里拆开的列表总是展开的,它的项下面的子行要展开那一项', () => { 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 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 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)] }]), 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)] }] } }, { ...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)] }] }]), 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(revealIn(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(revealIn(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(revealIn(blocks, 5, 140)).toEqual({ id: '1', expand: [] })
expect(revealPlan(blocks, 5, 165)).toEqual({ id: '1', expand: ['1/t/0/1'] }) expect(revealIn(blocks, 5, 165)).toEqual({ id: '1', expand: ['1/t/0/1'] })
expect(revealPlan(blocks, 9, 240)).toEqual({ id: '2', expand: ['2/p0'] }) expect(revealIn(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(revealIn(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(occurrenceIn(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(occurrenceIn(blocks, { start: 140, end: 147, kind: 'read' }, () => false)).toEqual({ id: '1', expand: [] })
}) })
it('名字在一行译文下面拆开的各项里:各项总是展开的,讲这个定义的项(字典的键)只闪它;项下面的下一层要展开那一项,实参表总是展开的', () => { it('名字在一行译文下面拆开的各项里:各项总是展开的,讲这个定义的项(字典的键)只闪它;项下面的下一层要展开那一项,实参表总是展开的', () => {
const blocks: BlockView[] = [ const blocks: WrittenBlock[] = [
{ ...textBlock('0', 'return', 0, [word('返回'), word('列表 2 项')]), items: [ { ...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('第 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 } }] }, { 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 } }] }, { ...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(revealIn(blocks, 0, 10)).toEqual({ id: '0', expand: [] })
expect(revealPlan(blocks, 0, 30)).toEqual({ id: '0', expand: [] }) expect(revealIn(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(revealIn(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(revealIn(blocks, 0, 45)).toEqual({ id: '0', expand: ['0/i/1'] })
expect(revealPlan(blocks, 5, 60)).toEqual({ id: '1', row: '1/i/0', expand: [] }) expect(revealIn(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(occurrenceIn(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(occurrenceIn(blocks, { start: 30, end: 37, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] })
}) })
it('名字在框里(套了几层都算)也是显示着它', () => { it('名字在框里(套了几层都算)也是显示着它', () => {
const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] } 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] }]), 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(revealIn(blocks, 5, 40)).toEqual({ id: '0', expand: [] })
expect(revealPlan(blocks, 5, 20)).toEqual({ id: '0', expand: [] }) expect(revealIn(blocks, 5, 20)).toEqual({ id: '0', expand: [] })
}) })
it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => { it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
const blocks: BlockView[] = [ const blocks: WrittenBlock[] = [
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, attributes: { kind: 'comment' }, concepts: [{ id: 'comment', label: '注释', icon: 'message-square-text' }], children: [], translation: { markdown: '见 `get_db`' } }, { 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, [ factBlock('1', 'function', 1, [
{ label: '说明', value: { markdown: '同 `get_db`' } }, { label: '说明', value: { markdown: '同 `get_db`' } },
{ label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] }, { 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、名字在属性行展开的子行里(参数):闪整块,连那一行一起展开', () => { it('翻译插件没标 defines、名字在属性行展开的子行里(参数):闪整块,连那一行一起展开', () => {
expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] }) expect(revealIn(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] })
expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] }) expect(revealIn(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] })
}) })
it('名字在属性行的值里:不用展开属性行', () => { it('名字在属性行的值里:不用展开属性行', () => {
expect(revealPlan(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] }) expect(revealIn(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] })
expect(revealPlan(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] }) expect(revealIn(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] })
}) })
it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => { it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => {
expect(revealPlan(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] }) expect(revealIn(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] })
expect(revealPlan(BLOCKS, 4, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) expect(revealIn(BLOCKS, 4, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
expect(revealPlan(BLOCKS, 8, 999)).toEqual({ id: '2', expand: [] }) expect(revealIn(BLOCKS, 8, 999)).toEqual({ id: '2', expand: [] })
}) })
it('一行落在后一块,不选已经结束的前一块;都包住时取最小的那块', () => { it('一行落在后一块,不选已经结束的前一块;都包住时取最小的那块', () => {
const blocks = [ const blocks: WrittenBlock[] = [
{ ...textBlock('0', 'class', 3, [word('类')]), endLine: 4 }, { ...textBlock('0', 'class', 3, [word('类')]), lines: [3, 4] },
{ ...textBlock('1', 'function', 1, [word('函数')]), endLine: 8 }, { ...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 inner = textBlock('0.0', 'return', 2, [word('返回')])
const outer = { ...textBlock('0', 'function', 1, [word('函数')], [inner]), endLine: 8 } const outer: WrittenBlock = { ...textBlock('0', 'function', 1, [word('函数')], [inner]), lines: [1, 8] }
expect(revealPlan([outer], 2, 999)).toEqual({ id: '0.0', expand: ['0'] }) expect(revealIn([outer], 2, 999)).toEqual({ id: '0.0', expand: ['0'] })
expect(revealPlan([outer], 6, 999)).toEqual({ id: '0', expand: [] }) expect(revealIn([outer], 6, 999)).toEqual({ id: '0', expand: [] })
}) })
it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => { it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => {
const plan = revealPlan(BLOCKS, 4, 102) const plan = revealIn(BLOCKS, 4, 102)
const hidden = (folds: Record<string, boolean>): string[] => plan?.expand.filter(id => collapsedIds(BLOCKS, folds).has(id)) ?? [] const hidden = (folds: Record<string, boolean>): string[] => plan?.expand.filter(id => collapsedIds(placed(BLOCKS), folds).has(id)) ?? []
expect(hidden({})).toEqual(['1']) expect(hidden({})).toEqual(['1'])
expect(hidden({ 1: false })).toEqual([]) expect(hidden({ 1: false })).toEqual([])
expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0']) 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 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 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]) 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(revealIn([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(revealIn([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, 300)).toEqual({ id: '0.1.0', expand: [propertyRowId('0', 3), '0.1', propertyRowId('0.1', 2), '0.1.0'] })
}) })
it('按行找:同一行有几块、一样大时取外层那块;行在所有块之前取第一块;没有块时没有目标', () => { it('按行找:同一行有几块、一样大时取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])] const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])]
expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] }) expect(revealIn(sameLine, 1, 999)).toEqual({ id: '0', expand: [] })
expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] }) expect(revealIn(sameLine, 0, 999)).toEqual({ id: '0', expand: [] })
expect(revealPlan([], 0, 0)).toBeUndefined() expect(revealIn([], 0, 0)).toBeUndefined()
}) })
}) })
@@ -231,7 +254,7 @@ describe('跳到定义:闪哪一处(defines)', () => {
* 0 def f(*args): f 在 4,*args 在 6..11 * 0 def f(*args): f 在 4,*args 在 6..11
* 1 a = 1; b = 2 一条语句声明两个变量,a 在 20,b 在 27 * 1 a = 1; b = 2 一条语句声明两个变量,a 在 20,b 在 27
*/ */
const blocks: BlockView[] = [ const blocks: WrittenBlock[] = [
{ {
...factBlock('0', 'function', 0, [ ...factBlock('0', 'function', 0, [
{ label: '名称', value: [name('f', 4)], defines: range(4, 5) }, { label: '名称', value: [name('f', 4)], defines: range(4, 5) },
@@ -246,25 +269,25 @@ describe('跳到定义:闪哪一处(defines)', () => {
] ]
it('父块的大范围盖住定义下标时,停在后面那个显示着这个名字的函数上,不闪父块', () => { it('父块的大范围盖住定义下标时,停在后面那个显示着这个名字的函数上,不闪父块', () => {
const parent = { const parent: WrittenBlock = {
...factBlock('0', 'class', 1, [{ label: '名称', value: [name('DeployActionError', 10)] }]), ...factBlock('0', 'class', 1, [{ label: '名称', value: [name('DeployActionError', 10)] }]),
endLine: 12, lines: [1, 12],
defines: range(0, 400), defines: range(0, 400),
} }
const fn = factBlock('1', 'function', 14, [{ label: '名称', value: [name('fetchAppDeployments', 80)] }]) 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('定义是块自己的名字:闪整块,属性行也标了它也一样', () => { it('定义是块自己的名字:闪整块,属性行也标了它也一样', () => {
expect(revealPlan(blocks, 0, 4)).toEqual({ id: '0', expand: [] }) expect(revealIn(blocks, 0, 4)).toEqual({ id: '0', expand: [] })
}) })
it('defines 是一段范围:定义落在中间也算(*args 的定义在 args 上)', () => { 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('一条语句声明几个变量:只闪那个变量的名称行', () => { 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 return undefined
} }
function flatten(list: BlockView[]): BlockView[] { function flatten(list: HostBlockView[]): HostBlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)]) 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) const block = flatten(views).find(view => view.id === plan?.id)
if (!block || !plan) if (!block || !plan)
return '' 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 }> { async function jump(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, needle: string): Promise<{ plan: RevealPlan | undefined, flashed: string }> {
const offset = source.indexOf(needle) const offset = source.indexOf(needle)
expect(offset, needle).toBeGreaterThanOrEqual(0) expect(offset, needle).toBeGreaterThanOrEqual(0)
const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) const views: HostBlockView[] = 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 plan = revealInSource(views, source, source.slice(0, offset).split('\n').length - 1, offset)
return { plan, flashed: flashed(views, plan, source) } 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('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 | ' }) 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)) 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 pySource = ['class NotFound(AppResponse):', ' """找不到。"""', ' def run(self):', ' pass'].join('\n')
const atPy = (needle: string): ReturnType<typeof jump> => jump(py, 'python', pythonZhTranslator, pySource, needle) const atPy = (needle: string): ReturnType<typeof jump> => 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('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 | 字符串 | ' }) 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)) 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 () => { it('库规则认领的块:类名闪整块,字段名只闪字段表里它那一行(先展开「字段」那一行);留下的方法照常,卡片默认折叠', async () => {
@@ -457,13 +480,13 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} })) toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} }))
const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => { const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => {
const offset = source.indexOf(needle) 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) } return { plan, flashed: flashed(views, plan, source) }
} }
expect(at('User')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'class User(Base):' }) 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('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(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 () => { it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => {
+15 -10
View File
@@ -1,3 +1,4 @@
import type { Placement } from './placement'
import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types' import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types'
import { blockPath } from './filter' import { blockPath } from './filter'
import { showsOccurrence, sourceOf } from './occurrences' import { showsOccurrence, sourceOf } from './occurrences'
@@ -50,7 +51,7 @@ function inlineSpan(pieces: Inline[], offset: number): number | undefined {
return inner return inner
} }
else { else {
const range = sourceOf(piece) const range = sourceOf(piece, 0)
if (range && range.start <= offset && offset < range.end) if (range && range.start <= offset && offset < range.end)
return range.end - range.start return range.end - range.start
} }
@@ -103,7 +104,7 @@ function definedItem(items: Iterable<PlacedItem>, offset: number): Spot | undefi
} }
/** /**
* 块里哪儿是 offset 处的定义,从具体到笼统: * 块里哪儿是 offset 处(块里的下标,从块的开头算)的定义,从具体到笼统:
* ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块; * ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块;
* ② 属性表、一行译文下面的子行里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它, * ② 属性表、一行译文下面的子行里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
* 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开; * 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开;
@@ -163,20 +164,23 @@ function showingItem(items: Iterable<PlacedItem>, test: (cell: PreviewText) => b
/** /**
* 跳到定义:目标见 locate。几块的源码范围都盖住这个下标时,留盖住它的那段最短的块—— * 跳到定义:目标见 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 best: { plan: RevealPlan, span: number } | undefined
let byLine: Placed | undefined let byLine: Placed | undefined
let byLineSpan = Number.POSITIVE_INFINITY let byLineSpan = Number.POSITIVE_INFINITY
for (const item of placed(blocks)) { 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)) 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] } } 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) { if (place.line <= line && line <= place.endLine) {
const span = item.block.endLine - item.block.startLine const span = place.endLine - place.line
if (span < byLineSpan) { if (span < byLineSpan) {
byLine = item byLine = item
byLineSpan = span byLineSpan = span
@@ -216,9 +220,10 @@ function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): st
* 用键盘跳到高亮着的名字的一处出现:页面上按先后第一处显示着它(有段和它重叠)的块,以及要展开的块、属性行和子行。 * 用键盘跳到高亮着的名字的一处出现:页面上按先后第一处显示着它(有段和它重叠)的块,以及要展开的块、属性行和子行。
* skip 的块(被筛掉的、正在编辑的、被正在编辑的祖先盖住的)里的不算;Markdown 说明里的名字不带下标,也不算。页面上哪儿都没有时是 undefined。 * skip 的块(被筛掉的、正在编辑的、被正在编辑的祖先盖住的)里的不算;Markdown 说明里的名字不带下标,也不算。页面上哪儿都没有时是 undefined。
*/ */
export function occurrencePlan(blocks: BlockView[], occurrence: Occurrence, skip: (id: string) => boolean): RevealPlan | undefined { export function occurrencePlan(blocks: BlockView[], placement: Placement, occurrence: Occurrence, skip: (id: string) => boolean): RevealPlan | undefined {
const test = (text: PreviewText): boolean => !('markdown' in text) && showsOccurrence(text, occurrence)
for (const { block, ancestors } of placed(blocks)) { 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) const expand = skip(block.id) ? undefined : showingSpot(block, test)
if (expand) if (expand)
return { id: block.id, expand: [...ancestors, ...expand] } return { id: block.id, expand: [...ancestors, ...expand] }
@@ -0,0 +1,20 @@
import type { BlockSpan, BlockView } from './types'
/**
* 测试里手写的块:照文件里的位置写,下标就是文件里的(各块的开头都在下标 0),lines 是块在文件里的 [首行, 尾行](不写是接着前一块的尾行)。
*/
export type WrittenBlock = Omit<BlockView, 'span' | 'children'> & { 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
})
}
+31 -19
View File
@@ -126,31 +126,41 @@ export interface BlockConcept {
/** 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个。 */ /** 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个。 */
export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName } 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' */ /** 在树里的位置,例如 '6.1.0' */
id: string id: string
/** 块的位置记号:问答挂在它上面(QaThreadView.anchor),文件别处改了照样对得上;webview 只拿它比相等,不拆开看 */ /** 块的位置记号:问答挂在它上面(QaThreadView.anchor),文件别处改了照样对得上;webview 只拿它比相等,不拆开看 */
anchor: string anchor: string
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table)。筛选用它;图标不看它,看 concepts */ /** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table)。筛选用它;图标不看它,看 concepts */
kind: string kind: string
/** 编辑范围的起止行,0 起:块的完整源码,头部连同各子孙块、结尾(块编辑时子孙都不画,见 coveredByEditing);编辑框里的源码按它从文档的源码里取(见 blockSource) */ /** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在块里的范围:跳到的定义就是它时闪整块 */
startLine: number
endLine: number
/** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */
defines?: SourceRange defines?: SourceRange
/** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */ /** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */
attributes: Record<string, string> attributes: Record<string, string>
/** 这块认出来的概念,从左到右。webview 按图标名画,不自己用种类查 */ /** 这块认出来的概念,从左到右。webview 按图标名画,不自己用种类查 */
concepts: BlockConcept[] concepts: BlockConcept[]
/** 「重写注释」写到哪(扩展端用,这边只看有没有、是换掉还是新写);没有就不显示按钮 */ /** 「重写注释」是换掉已有的注释,还是新写一段(扩展端按块的记号找写到哪);没有就不显示按钮 */
commentTarget?: CommentTarget commentTarget?: 'replace' | 'insert'
/** 库规则的卡片才有:卡片边框的颜色,筛选栏里这个种类的名字。和类块一样默认收起子块 */ /** 库规则的卡片才有:卡片边框的颜色,筛选栏里这个种类的名字。和类块一样默认收起子块 */
badge?: KindBadge badge?: KindBadge
/** 库规则挂在父卡片子行上的子块才有:展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里;见 BlockSlot */ /** 库规则挂在父卡片子行上的子块才有:展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里;见 BlockSlot */
slot?: BlockSlot slot?: BlockSlot
/** 能看调用树的块才有(有名字的函数、类,讲 HTTP 接口的卡片):有它才摆调用树按钮 */ /** 能看调用树的块才有(有名字的函数、类,讲 HTTP 接口的卡片):有它才摆调用树按钮 */
callTree?: CallTreeTarget callTree?: CallTreeTarget
children: BlockView[]
} }
/** 一块能看调用树:接口卡片带上它讲的接口(扩展端查前端调用者用,这边只看有没有 callTree)。 */ /** 一块能看调用树:接口卡片带上它讲的接口(扩展端查前端调用者用,这边只看有没有 callTree)。 */
@@ -167,20 +177,21 @@ export interface BlockSlot {
item: number item: number
} }
/**
* 「重写注释」写到哪,行号 0 起:replace 换掉 startLine..endLine 这几行(块前面的普通注释;文件开头的说明卡片是它自己),
* insert 是块还没有注释,新写一段插在 line 这一行上面
*/
export type CommentTarget
= | { kind: 'replace', startLine: number, endLine: number }
| { kind: 'insert', line: number }
/** /**
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文; * 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文;
* 一行译文下面依次是:跨几行写的字面量拆开的各项(items),跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文), * 一行译文下面依次是:跨几行写的字面量拆开的各项(items),跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文),
* 几行属性(rows:「注释」一行) * 几行属性(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 } export interface TranslatorOption { id: string, label: string }
@@ -232,7 +243,8 @@ export interface Occurrence {
/** /**
* 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径)、定义处那一行(0 起), * 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径)、定义处那一行(0 起),
* 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的,不是页面上这份文档的) * 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片;它自己当那个文件的顶层第一块,span 从文件开头算),
* 显示成中文名的名字(下标是那个文件里的,不是页面上这份文档的)
*/ */
export interface DefinitionPeek { export interface DefinitionPeek {
path: string path: string
@@ -443,7 +455,7 @@ export interface ChangedBlock {
export interface RemovedView { export interface RemovedView {
parent: string parent: string
after?: string after?: string
block: BlockView block: CardView
line: number line: number
kindName: string kindName: string
} }
@@ -1,6 +1,8 @@
import type { Placement } from './placement'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { DefinitionLinks } from './definitionLinks' import { DefinitionLinks } from './definitionLinks'
import { documentNameAt } from './definitionPeek' import { documentNameAt } from './definitionPeek'
import { nameOffset } from './placement'
import { onMessage, postMessage } from './vscode' import { onMessage, postMessage } from './vscode'
/** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */ /** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */
@@ -10,17 +12,17 @@ function modifierHeld(event: MouseEvent | KeyboardEvent): boolean {
return IS_MAC ? event.metaKey : event.ctrlKey return IS_MAC ? event.metaKey : event.ctrlKey
} }
function offsetOf(el: HTMLElement | null): number | undefined { /** 指针下的名字、修饰键按没按;把链接样式(data-link)挂到指着的那个名字上。名字在文件里的下标按 placement 换算。 */
return el?.dataset.offset === undefined ? undefined : Number(el.dataset.offset)
}
/** 指针下的名字、修饰键按没按;把链接样式(data-link)挂到指着的那个名字上。 */
class Pointer { class Pointer {
private hovered: HTMLElement | null = null private hovered: HTMLElement | null = null
private held = false private held = false
private linked: HTMLElement | null = null 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 { move(target: EventTarget | null, held: boolean): void {
this.hovered = documentNameAt(target) this.hovered = documentNameAt(target)
@@ -35,7 +37,7 @@ class Pointer {
/** 把按住修饰键时指着的名字告诉 links,再按它的回答挂上或摘掉链接样式 */ /** 把按住修饰键时指着的名字告诉 links,再按它的回答挂上或摘掉链接样式 */
sync(): void { sync(): void {
const offset = offsetOf(this.hovered) const offset = this.offsetOf(this.hovered)
this.links.point(this.held ? offset : undefined) this.links.point(this.held ? offset : undefined)
const link = offset !== undefined && offset === this.links.linked ? this.hovered : null const link = offset !== undefined && offset === this.links.linked ? this.hovered : null
if (link === this.linked) if (link === this.linked)
@@ -48,11 +50,11 @@ class Pointer {
/** /**
* 和 VS Code 编辑器里一样:按住 ⌘ / Ctrl、鼠标停在查得到定义的名字上时,名字显示成链接,点一下跳到定义。 * 和 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 [links] = useState(() => new DefinitionLinks(postMessage))
const [pointer] = useState(() => new Pointer(links)) const [pointer] = useState(() => new Pointer(links, placement))
useEffect(() => { useEffect(() => {
links.setVersion(version) links.setVersion(version)
@@ -69,7 +71,7 @@ export function useDefinitionLinks(version: number | undefined): void {
const onBlur = (): void => pointer.hold(false) const onBlur = (): void => pointer.hold(false)
// ⌘ / Ctrl + 点击名字归跳转:拦下这次点击,不让它再触发块上的编辑切换、折叠 // ⌘ / Ctrl + 点击名字归跳转:拦下这次点击,不让它再触发块上的编辑切换、折叠
const onClick = (event: MouseEvent): void => { 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 return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -1,11 +1,13 @@
import type { PeekTarget, ShownPeek } from './definitionPeek' import type { PeekTarget, ShownPeek } from './definitionPeek'
import type { Placement } from './placement'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { DefinitionPeeks, documentNameAt, inPeek, linkPeekSource } from './definitionPeek' import { DefinitionPeeks, documentNameAt, inPeek, linkPeekSource } from './definitionPeek'
import { nameOffset } from './placement'
import { commentLinkOf } from './repoLink' import { commentLinkOf } from './repoLink'
import { onMessage, postMessage } from './vscode' import { onMessage, postMessage } from './vscode'
/** 指针下能弹定义卡片的一处:注释里指到符号的链接,或页面上这份文档带下标的名字;浮窗里的不算。 */ /** 指针下能弹定义卡片的一处:注释里指到符号的链接,或页面上这份文档带下标的名字(下标按 placement 换成文件里的);浮窗里的不算。 */
function peekTargetAt(target: EventTarget | null): PeekTarget<HTMLElement> | null { function peekTargetAt(target: EventTarget | null, placement: Placement): PeekTarget<HTMLElement> | null {
if (!(target instanceof Element) || inPeek(target)) if (!(target instanceof Element) || inPeek(target))
return null return null
const link = target.closest<HTMLAnchorElement>('.cr-markdown a') const link = target.closest<HTMLAnchorElement>('.cr-markdown a')
@@ -14,7 +16,8 @@ function peekTargetAt(target: EventTarget | null): PeekTarget<HTMLElement> | nul
return source ? { anchor: link, source } : null return source ? { anchor: link, source } : null
} }
const name = documentNameAt(target) 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 { function isInPeek(target: EventTarget | null): boolean {
@@ -24,9 +27,9 @@ function isInPeek(target: EventTarget | null): boolean {
/** /**
* 和 VS Code 编辑器里的悬停一样:鼠标停在带下标的名字、注释里指到符号的链接上,过一会儿(editor.hover.delay)弹出定义所在那块的卡片, * 和 VS Code 编辑器里的悬停一样:鼠标停在带下标的名字、注释里指到符号的链接上,过一会儿(editor.hover.delay)弹出定义所在那块的卡片,
* 移开名字和浮窗过一会儿(editor.hover.hidingDelay)收起,Esc、滚动、按下鼠标立刻收(见 DefinitionPeeks)。 * 移开名字和浮窗过一会儿(editor.hover.hidingDelay)收起,Esc、滚动、按下鼠标立刻收(见 DefinitionPeeks)。
* version 是当前显示的文档版本,名字的下标属于它。返回弹着的卡片和点标题时的跳转。 * version 是当前显示的文档版本,名字的下标属于它;placement 是它各块在文件里的位置。返回弹着的卡片和点标题时的跳转。
*/ */
export function useDefinitionPeek(version: number | undefined): { shown: ShownPeek<HTMLElement> | undefined, jump: () => void } { export function useDefinitionPeek(version: number | undefined, placement: () => Placement): { shown: ShownPeek<HTMLElement> | undefined, jump: () => void } {
const [shown, setShown] = useState<ShownPeek<HTMLElement> | undefined>() const [shown, setShown] = useState<ShownPeek<HTMLElement> | undefined>()
const [peeks] = useState(() => new DefinitionPeeks<HTMLElement>(postMessage, setShown)) const [peeks] = useState(() => new DefinitionPeeks<HTMLElement>(postMessage, setShown))
@@ -39,7 +42,7 @@ export function useDefinitionPeek(version: number | undefined): { shown: ShownPe
if (isInPeek(event.target)) if (isInPeek(event.target))
peeks.enterPopup() peeks.enterPopup()
else else
peeks.point(peekTargetAt(event.target)) peeks.point(peekTargetAt(event.target, placement()))
} }
const onOut = (event: PointerEvent): void => { const onOut = (event: PointerEvent): void => {
if (event.relatedTarget === null) if (event.relatedTarget === null)
@@ -81,7 +84,7 @@ export function useDefinitionPeek(version: number | undefined): { shown: ShownPe
window.removeEventListener('scroll', onScroll, true) window.removeEventListener('scroll', onScroll, true)
peeks.dispose() peeks.dispose()
} }
}, [peeks]) }, [peeks, placement])
return { shown, jump: () => peeks.jump() } return { shown, jump: () => peeks.jump() }
} }
@@ -1,10 +1,12 @@
import type { Click, Highlight, ScrollPosition } from './occurrences' import type { Click, Highlight, ScrollPosition } from './occurrences'
import type { Placement } from './placement'
import type { TextMarks } from './SegmentText' import type { TextMarks } from './SegmentText'
import type { Store } from './store' import type { Store } from './store'
import type { BlockView, Occurrence } from './types' import type { BlockView, Occurrence } from './types'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek' import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek'
import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences' import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences'
import { nameOffset, NO_PLACEMENT } from './placement'
import { occurrencePlan } from './reveal' import { occurrencePlan } from './reveal'
import { useStore } from './store' import { useStore } from './store'
import { showInViewport } from './useReveal' import { showInViewport } from './useReveal'
@@ -19,6 +21,8 @@ const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset
/** 用键盘跳到一处出现时要用的页面状态。 */ /** 用键盘跳到一处出现时要用的页面状态。 */
export interface OccurrencePage { export interface OccurrencePage {
blocks: BlockView[] blocks: BlockView[]
/** 各块在文件里的位置:点中的名字、各处出现按它和文件里的下标对上 */
placement: Placement
/** 这块里的出现跳不到:被筛掉的、正在编辑的、被正在编辑的祖先盖住的 */ /** 这块里的出现跳不到:被筛掉的、正在编辑的、被正在编辑的祖先盖住的 */
skip: (id: string) => boolean skip: (id: string) => boolean
/** 眼下折叠着的块、属性行和子行 */ /** 眼下折叠着的块、属性行和子行 */
@@ -27,16 +31,18 @@ export interface OccurrencePage {
expand: (ids: string[]) => void 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 target = event.target instanceof Element ? event.target : null
const name = documentNameAt(target) const name = documentNameAt(target)
const offset = name ? nameOffset(name, placement) : undefined
const selection = window.getSelection() const selection = window.getSelection()
return { return {
modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey, modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey,
count: event.detail, count: event.detail,
selecting: selection !== null && !selection.isCollapsed, 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, control: target?.closest(CONTROLS) != null,
} }
} }
@@ -122,7 +128,7 @@ export function useOccurrences(version: number | undefined, translatorId: string
keyScroll.forget() keyScroll.forget()
} }
const current = latestPage.current const current = latestPage.current
const planOf = (occurrence: Occurrence): ReturnType<typeof occurrencePlan> => current && occurrencePlan(current.blocks, occurrence, current.skip) const planOf = (occurrence: Occurrence): ReturnType<typeof occurrencePlan> => current && occurrencePlan(current.blocks, current.placement, occurrence, current.skip)
const target = current && occurrences.move(step, occurrence => planOf(occurrence) !== undefined) const target = current && occurrences.move(step, occurrence => planOf(occurrence) !== undefined)
const plan = target && planOf(target) const plan = target && planOf(target)
if (!current || !plan) { if (!current || !plan) {
@@ -135,7 +141,7 @@ export function useOccurrences(version: number | undefined, translatorId: string
setMoves(count => count + 1) 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) moveFromButton.current = step => move(step, false)
// 任何键都照常交给 VS Code。能跳时,默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)取消浏览器滚到底 / 滚到顶,但不停冒泡,命令还是收得到 // 任何键都照常交给 VS Code。能跳时,默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)取消浏览器滚到底 / 滚到顶,但不停冒泡,命令还是收得到
const onKeyDown = (event: KeyboardEvent): void => { const onKeyDown = (event: KeyboardEvent): void => {
@@ -3,6 +3,7 @@ import type { DocumentMessage, Reveal } from './types'
import { useLayoutEffect } from 'react' import { useLayoutEffect } from 'react'
import { blockPath } from './filter' import { blockPath } from './filter'
import { pageLayout } from './pageLayout' import { pageLayout } from './pageLayout'
import { placementOf } from './placement'
import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal' import { revealPlan, scrollTopToShow, uncoveredPlan } from './reveal'
import { stickyHeight } from './useStickyScroll' import { stickyHeight } from './useStickyScroll'
@@ -44,7 +45,7 @@ export function showInViewport(el: HTMLElement, beforeScroll?: (position: Scroll
window.scrollTo(position.x, position.y) window.scrollTo(position.x, position.y)
} }
/** 这块自己显示着的、下标就是定义处的那个名字。子块里的同名下标不算,免得闪到旁边那张卡片。 */ /** 这块自己显示着的、下标(块里的)就是定义处的那个名字。子块里的同名下标不算,免得闪到旁边那张卡片。 */
function nameAt(block: HTMLElement, offset: number): HTMLElement | null { function nameAt(block: HTMLElement, offset: number): HTMLElement | null {
for (const el of block.querySelectorAll<HTMLElement>('[data-offset]')) { for (const el of block.querySelectorAll<HTMLElement>('[data-offset]')) {
if (Number(el.dataset.offset) !== offset) if (Number(el.dataset.offset) !== offset)
@@ -83,7 +84,8 @@ export function useReveal(
done() done()
return 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 plan = found && uncoveredPlan(found, covered)
const path = plan ? blockPath(plan.id) : [] const path = plan ? blockPath(plan.id) : []
if (path.some(id => filtered.has(id))) { if (path.some(id => filtered.has(id))) {
@@ -98,7 +100,8 @@ export function useReveal(
// 块正在编辑时页面上没有属性表,就闪整块 // 块正在编辑时页面上没有属性表,就闪整块
const block = plan ? document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`) : null const block = plan ? document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`) : null
const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`) const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`: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 const el = row ?? name ?? block
if (el) { if (el) {
showInViewport(el) showInViewport(el)