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