diff --git a/vscode/package.json b/vscode/package.json index 2e0382f..632d781 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -1321,6 +1321,21 @@ "command": "superpowers.codeReader.previousOccurrence", "title": "跳到上一处高亮", "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.find", + "title": "查找", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.findNext", + "title": "查找下一处", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.findPrevious", + "title": "查找上一处", + "category": "代码阅读" } ], "keybindings": [ @@ -1335,6 +1350,24 @@ "key": "ctrl+up", "mac": "cmd+up", "when": "activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.hasOccurrences" + }, + { + "command": "superpowers.codeReader.find", + "key": "ctrl+f", + "mac": "cmd+f", + "when": "activeCustomEditorId == superpowers.codeReader.preview" + }, + { + "command": "superpowers.codeReader.findNext", + "key": "f3", + "mac": "cmd+g", + "when": "activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.findOpen" + }, + { + "command": "superpowers.codeReader.findPrevious", + "key": "shift+f3", + "mac": "shift+cmd+g", + "when": "activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.findOpen" } ], "submenus": [ @@ -2940,6 +2973,18 @@ "command": "superpowers.codeReader.previousOccurrence", "when": "activeCustomEditorId == superpowers.codeReader.preview" }, + { + "command": "superpowers.codeReader.find", + "when": "activeCustomEditorId == superpowers.codeReader.preview" + }, + { + "command": "superpowers.codeReader.findNext", + "when": "activeCustomEditorId == superpowers.codeReader.preview" + }, + { + "command": "superpowers.codeReader.findPrevious", + "when": "activeCustomEditorId == superpowers.codeReader.preview" + }, { "command": "superpowers.codeReader.copyFileName", "when": "false" diff --git a/vscode/src/codeReader/editor/findCommands.test.ts b/vscode/src/codeReader/editor/findCommands.test.ts new file mode 100644 index 0000000..eec4b95 --- /dev/null +++ b/vscode/src/codeReader/editor/findCommands.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs' +import * as path from 'node:path' +import { describe, expect, it } from 'vitest' +import { FIND, FIND_CONTEXT, FIND_NEXT, FIND_PREVIOUS } from './findCommands' + +interface Manifest { + contributes: { + commands: { command: string, title: string, category?: string }[] + keybindings: { command: string, key: string, mac?: string, when?: string }[] + customEditors: { viewType: string }[] + menus: { commandPalette: { command: string, when: string }[] } + } +} + +const manifest = JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest +const { commands, keybindings, customEditors, menus } = manifest.contributes +const PREVIEW = customEditors[0].viewType +const FIND_COMMANDS = [FIND, FIND_NEXT, FIND_PREVIOUS] + +describe('预览里的查找栏:package.json 里的命令和快捷键', () => { + it('三条命令叫「代码阅读:查找 / 查找下一处 / 查找上一处」', () => { + expect(commands.filter(c => FIND_COMMANDS.includes(c.command)).map(c => `${c.category}:${c.title}`)) + .toEqual(['代码阅读:查找', '代码阅读:查找下一处', '代码阅读:查找上一处']) + }) + + it('⌘F(别的系统 Ctrl+F)在代码阅读预览是活动编辑器时打开查找栏;⌘G / ⇧⌘G(别的系统 F3 / Shift+F3)只在查找栏开着时跳', () => { + const open = `activeCustomEditorId == ${PREVIEW} && ${FIND_CONTEXT}` + expect(keybindings.filter(k => FIND_COMMANDS.includes(k.command))).toEqual([ + { command: FIND, key: 'ctrl+f', mac: 'cmd+f', when: `activeCustomEditorId == ${PREVIEW}` }, + { command: FIND_NEXT, key: 'f3', mac: 'cmd+g', when: open }, + { command: FIND_PREVIOUS, key: 'shift+f3', mac: 'shift+cmd+g', when: open }, + ]) + }) + + it('命令面板里只在代码阅读预览是活动编辑器时出现', () => { + expect(menus.commandPalette.filter(item => FIND_COMMANDS.includes(item.command))).toEqual( + FIND_COMMANDS.map(command => ({ command, when: `activeCustomEditorId == ${PREVIEW}` })), + ) + }) + + it('扩展端不写死键,只转命令;Enter、Esc 这些查找栏自己的键归页面', () => { + const sources = ['findCommands.ts', 'previewEditor.ts', 'messages.ts', '../index.ts'].map(file => readFileSync(path.resolve(__dirname, file), 'utf8')) + for (const text of sources) + expect(text).not.toMatch(/cmd\+|ctrl\+|f3|Escape|'Enter'/) + const page = ['FindBar.tsx', 'useFind.ts'].map(file => readFileSync(path.resolve(__dirname, '../../../webview-ui/src/codeReader', file), 'utf8')).join('\n') + expect(page).toContain(`'Escape'`) + expect(page).toContain(`'Enter'`) + expect(page).toContain(`msg.type === 'moveFind'`) + }) +}) diff --git a/vscode/src/codeReader/editor/findCommands.ts b/vscode/src/codeReader/editor/findCommands.ts new file mode 100644 index 0000000..99d2cb4 --- /dev/null +++ b/vscode/src/codeReader/editor/findCommands.ts @@ -0,0 +1,7 @@ +/** 预览里的查找栏:三条命令、快捷键 when 子句用的上下文键。键写在 package.json 里,扩展端只把命令转给页面。 */ + +export const FIND = 'superpowers.codeReader.find' +export const FIND_NEXT = 'superpowers.codeReader.findNext' +export const FIND_PREVIOUS = 'superpowers.codeReader.findPrevious' +/** 活动的预览里查找栏开着、页面有焦点:查找下一处 / 上一处的快捷键这时才归它。 */ +export const FIND_CONTEXT = 'superpowers.codeReader.findOpen' diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 35bf8d8..81ee478 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -197,6 +197,8 @@ export interface StickyScrollSettings { * rewriteEnd:askId 那一轮重写结束了:done 是模型答完、这一轮记下了,带交回的正文(text)、存下的那条的 id,没写进文件时带原因(error); * failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 + * openFind:打开查找栏,由「查找」命令发给活动的预览 + * moveFind:查找栏里跳到下一处(step 1)/ 上一处(step -1)命中,由「查找下一处 / 上一处」命令发给查找栏开着的预览 * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的才有,见 [对比基准](vscode/src/codeReader/diff/diffBase.ts#DiffBase)):每发一份文档之后算好了补发, * 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动、这个标签页换了从哪儿打开)再发;null 是不比了(退出了审查、改动提交了、从别处点开了) @@ -219,6 +221,8 @@ export type ExtensionToWebview | ({ type: 'stickyScroll' } & StickyScrollSettings) | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } | { type: 'moveOccurrence', step: 1 | -1 } + | { type: 'openFind' } + | { type: 'moveFind', step: 1 | -1 } | { type: 'displayNames', version: number, names: ShownName[] } | ({ type: 'filter' } & FilterState) | ({ type: 'codeColors' } & CodeColors) @@ -262,6 +266,7 @@ export type ExtensionToWebview * selectProfile:kind 那种对话换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后把这种的 profiles 发给所有预览 * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发; * 扩展端按它设 when 子句用的上下文键 + * findNavigation:眼下查找栏的快捷键归不归这个页面(查找栏开着、页面有焦点),变了才发;扩展端按它设 when 子句用的上下文键 * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录; * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference), * 从那块的声明起一层层往外找本文件定义的,再是导入的(见 symbolLinks.ts) @@ -291,6 +296,7 @@ export type WebviewToExtension | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectProfile', kind: ChatKind, id: string } | { type: 'occurrenceNavigation', available: boolean } + | { type: 'findNavigation', available: boolean } | { type: 'openRepoLink', path?: string, symbol?: string, block?: string } | { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] } | { type: 'checkRepoImages', id: number, paths: string[] } diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 226d0c8..36ce75c 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { commands, window, workspace } from 'vscode' import { documentMessage } from './documentMessage' import { documentSymbolNodes } from './documentSymbols' +import { FIND_CONTEXT } from './findCommands' import { OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './previewEditor' @@ -563,3 +564,46 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () = expect(page.posted()).toContainEqual({ type: 'repoLinkBroken', symbol: 'get_agent_admitted', block: HELPER }) }) }) + +describe('预览里的查找栏', () => { + const sent = (page: ReturnType): ExtensionToWebview[] => page.posted().filter(msg => msg.type === 'openFind' || msg.type === 'moveFind') + + it('「查找」发给活动的预览;「查找下一处 / 上一处」发给查找栏开着、有焦点的那个,都没有时发给活动的;还没 ready 的不发', async () => { + const { provider, document } = opening() + const first = tab() + const second = tab() + await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, first.panel)) + await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, second.panel)) + provider.find() + expect([...sent(first), ...sent(second)]).toEqual([]) + + await first.send({ type: 'ready' }) + await second.send({ type: 'ready' }) + first.hide() + provider.find() + expect(sent(first)).toEqual([]) + expect(sent(second)).toEqual([{ type: 'openFind' }]) + + second.clear() + await first.send({ type: 'findNavigation', available: true }) + provider.moveFind(1) + expect(sent(first)).toEqual([{ type: 'moveFind', step: 1 }]) + expect(sent(second)).toEqual([]) + + await first.send({ type: 'findNavigation', available: false }) + provider.moveFind(-1) + expect(sent(second)).toEqual([{ type: 'moveFind', step: -1 }]) + }) + + it('页面报来查找栏开着、有焦点:设上下文键,⌘G 这时才归查找栏;变了才设', async () => { + const { provider, document } = opening() + const page = tab() + await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, page.panel)) + vi.mocked(commands.executeCommand).mockClear() + await page.send({ type: 'findNavigation', available: true }) + await page.send({ type: 'findNavigation', available: true }) + await page.send({ type: 'findNavigation', available: false }) + expect(vi.mocked(commands.executeCommand).mock.calls.filter(([command, key]) => command === 'setContext' && key === FIND_CONTEXT)) + .toEqual([['setContext', FIND_CONTEXT, true], ['setContext', FIND_CONTEXT, false]]) + }) +}) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index e6146d7..c5a1fa6 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -44,6 +44,7 @@ import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, r import { DefinitionSources } from './definitionSources' import { documentMessage, pageDocument } from './documentMessage' import { documentSymbolNodes } from './documentSymbols' +import { FIND_CONTEXT } from './findCommands' import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { occurrencesOf } from './occurrences' import { languageOf, openingFor } from './openTarget' @@ -80,6 +81,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly requests = new Map() /** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */ readonly navigation = new NavigationContext(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value)) + /** 哪个预览眼下查找栏开着、有焦点,跟着它设查找下一处 / 上一处快捷键 when 子句用的上下文键 */ + readonly finding = new NavigationContext(value => void commands.executeCommand('setContext', FIND_CONTEXT, value)) /** 调用的名字的定义是哪种块:各预览共用,定义所在的文件同一版只解析一次 */ readonly definitionKinds: DefinitionKinds @@ -153,6 +156,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { panel.onDidDispose(() => { this.previews.delete(preview) this.navigation.update(preview, false) + this.finding.update(preview, false) preview.dispose() }) } @@ -162,6 +166,16 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { this.navigation.target(this.previews, preview => preview.active)?.moveOccurrence(step) } + /** 「查找」:打开查找栏。发给查找栏开着、有焦点的那个预览,没有就发给活动的预览。 */ + find(): void { + this.finding.target(this.previews, preview => preview.active)?.find() + } + + /** 「查找下一处 / 上一处」:发给查找栏开着、有焦点的那个预览,从命令面板运行时发给活动的预览。 */ + moveFind(step: 1 | -1): void { + this.finding.target(this.previews, preview => preview.active)?.moveFind(step) + } + /** 让已打开的预览按当前注册表重发;给了 languageId 就只刷这种语言的。 */ refresh(languageId?: string): void { for (const preview of this.previews) { @@ -539,6 +553,18 @@ class Preview { this.post({ type: 'moveOccurrence', step }) } + /** 打开查找栏;页面还没 ready 就算了。 */ + find(): void { + if (this.ready) + this.post({ type: 'openFind' }) + } + + /** 查找栏里跳到下一处 / 上一处命中;页面还没 ready 就算了(还没有查找栏)。 */ + moveFind(step: 1 | -1): void { + if (this.ready) + this.post({ type: 'moveFind', step }) + } + /** 开着时又被点开(见 [PreviewEditorProvider.open]):这次说了从哪儿打开的就按它重新比,没说的照旧;要滚到哪、要不要聚焦输入框照这次的。 */ reopen(request: OpenRequest): void { if (request.from !== undefined) { @@ -744,6 +770,9 @@ class Preview { return case 'occurrenceNavigation': this.owner.navigation.update(this, msg.available) + return + case 'findNavigation': + this.owner.finding.update(this, msg.available) } } diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 817976c..cd0ff56 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -12,6 +12,7 @@ import { shellCommandRules } from './commands/shellCommands' import { systemCommandRules } from './commands/systemCommands' import { previewDiffBases } from './diff/diffBase' import { BlockFilter } from './editor/blockFilter' +import { FIND, FIND_NEXT, FIND_PREVIOUS } from './editor/findCommands' import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation' import { languageOf, openingFor } from './editor/openTarget' import { PATH_PREVIEW_SCHEME } from './editor/pathPreview' @@ -212,6 +213,9 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => switcher.showSource(args)), commands.registerCommand(NEXT_OCCURRENCE, () => previews.moveOccurrence(1)), commands.registerCommand(PREVIOUS_OCCURRENCE, () => previews.moveOccurrence(-1)), + commands.registerCommand(FIND, () => previews.find()), + commands.registerCommand(FIND_NEXT, () => previews.moveFind(1)), + commands.registerCommand(FIND_PREVIOUS, () => previews.moveFind(-1)), ) const api: CodeReaderApi = registry diff --git a/vscode/webview-ui/src/codeReader/App.test.tsx b/vscode/webview-ui/src/codeReader/App.test.tsx index 79001a8..6681cbf 100644 --- a/vscode/webview-ui/src/codeReader/App.test.tsx +++ b/vscode/webview-ui/src/codeReader/App.test.tsx @@ -343,3 +343,139 @@ describe('一条消息只重画受影响的卡片', () => { expect(container.querySelectorAll('.cr-qa')).toHaveLength(2) }) }) + +describe('查找栏', () => { + /** + * 0 def load(): 函数默认折叠,里面那句也有 path + * 1 return path + * 2 path = 1 + */ + function statement(id: string, text: string, offset?: number): UnhashedBlock { + return { ...block(id, id), kind: 'statement', callTree: undefined, translation: [{ text, role: 'variable', ...(offset !== undefined && { offset }) }] } as UnhashedBlock + } + const DOC = documentOf(7, [ + { ...block('0', 'load'), translation: [{ text: 'load', role: 'function', offset: 4 }], children: [statement('0.0', 'return path')] }, + statement('1', 'path = 1'), + ]) + const input = (): HTMLInputElement | null => container.querySelector('.cr-find-bar input') + const count = (): string | null | undefined => container.querySelector('.cr-find-count')?.textContent + const navigation = (): boolean[] => host.posted.flatMap(msg => (msg as WebviewToExtension).type === 'findNavigation' ? [(msg as { available: boolean }).available] : []) + + async function type(text: string): Promise { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input(), text) + input()!.dispatchEvent(new Event('input', { bubbles: true })) + }) + // 打字时等一下再找 + await act(async () => new Promise(resolve => setTimeout(resolve, 200))) + } + + async function press(key: string, init: KeyboardEventInit = {}): Promise { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }) + await act(async () => input()!.dispatchEvent(event)) + return event + } + + it('⌘F 打开查找栏、聚焦输入框;写了字马上找,数出整份文件里的(折叠着的块里的也算),跳到那处时展开它外面的块', async () => { + await load() + await deliver(DOC) + expect(input()).toBeNull() + expect(container.querySelector('[data-id="0.0"]')).toBeNull() + await deliver({ type: 'openFind' }) + expect(document.activeElement).toBe(input()) + await type('PATH') + expect(count()).toBe('1/2') + expect(container.querySelector('[data-id="0.0"]')).not.toBeNull() + expect(saved?.folds).toEqual({ 0: false }) + }) + + it('enter 下一处、Shift+Enter 上一处,到头了从另一头接着;⌘G(扩展端发来 moveFind)也跳;没有命中写「无结果」', async () => { + await load() + await deliver(DOC) + await deliver({ type: 'openFind' }) + await type('path') + expect(count()).toBe('1/2') + expect((await press('Enter')).defaultPrevented).toBe(true) + expect(count()).toBe('2/2') + await press('Enter') + expect(count()).toBe('1/2') + await press('Enter', { shiftKey: true }) + expect(count()).toBe('2/2') + await deliver({ type: 'moveFind', step: 1 }) + expect(count()).toBe('1/2') + await deliver({ type: 'moveFind', step: -1 }) + expect(count()).toBe('2/2') + await type('nothing') + expect(count()).toBe('无结果') + }) + + it('esc 关掉查找栏、焦点还给页面,单击名字的高亮照旧;再按 ⌘F 还是原来的字、全选着', async () => { + await load() + await deliver(DOC) + await act(async () => container.querySelector('[data-offset="4"]')!.click()) + const asked = [...host.posted].reverse().find((msg): msg is Extract => (msg as WebviewToExtension).type === 'findOccurrences')! + await deliver({ type: 'occurrencesFound', id: asked.id, occurrences: [{ start: 4, end: 8, kind: 'write' }] }) + expect(container.querySelector('.cr-occurrence-name')?.textContent).toBe('load') + + await deliver({ type: 'openFind' }) + await type('path') + expect((await press('Escape')).defaultPrevented).toBe(true) + expect(input()).toBeNull() + expect(document.activeElement).toBe(document.body) + expect(container.querySelector('.cr-occurrence-name')?.textContent).toBe('load') + + await deliver({ type: 'openFind' }) + expect(input()?.value).toBe('path') + expect([input()?.selectionStart, input()?.selectionEnd]).toEqual([0, 4]) + expect(count()).toBe('1/2') + }) + + it('页面上选中了一行字:⌘F 拿它填进去,从选中的那处开始找', async () => { + await load() + await deliver(DOC) + const text = container.querySelector('[data-id="1"] .cr-code span')!.firstChild! + const range = document.createRange() + range.setStart(text, 0) + range.setEnd(text, 4) + window.getSelection()!.removeAllRanges() + window.getSelection()!.addRange(range) + await deliver({ type: 'openFind' }) + expect(input()?.value).toBe('path') + await act(async () => new Promise(resolve => setTimeout(resolve, 200))) + expect(count()).toBe('2/2') + }) + + it('查找栏开着、页面有焦点时告诉扩展端 ⌘G 归它,关了就不归', async () => { + await load() + await deliver(DOC) + await deliver({ type: 'openFind' }) + expect(navigation().at(-1)).toBe(true) + await press('Escape') + expect(navigation().at(-1)).toBe(false) + }) + + it('换了一版文档:照旧按原来的字找,当前那处停在原来那处', async () => { + await load() + await deliver(DOC) + await deliver({ type: 'openFind' }) + await type('path') + await press('Enter') + expect(count()).toBe('2/2') + await deliver(documentOf(8, [statement('9', 'path first'), ...DOC.blocks as unknown as UnhashedBlock[]])) + expect(input()?.value).toBe('path') + expect(count()).toBe('3/3') + }) + + it('写字、跳下一处:里面的字都不重画(高亮直接画在页面上),只有要展开的那块重画', async () => { + await load() + await deliver(DOC) + await deliver({ type: 'openFind' }) + renders.start() + await type('path') + await deliver({ type: 'moveFind', step: 1 }) + await deliver({ type: 'moveFind', step: 1 }) + const counts = renders.stop() + expect(counts.SegmentText).toBeUndefined() + expect(counts.BlockItem ?? 0).toBeLessThanOrEqual(1) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 9db78ba..9d1c83b 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -16,6 +16,8 @@ import { nameMap } from './displayNames' import { startEditing } from './editing' import { keptWhileEditing } from './filter' import { FilterBar } from './FilterBar' +import { FindBar } from './FindBar' +import { FindPainter } from './findHighlights' import { OverviewRuler } from './OverviewRuler' import { pageLayout } from './pageLayout' import { createPageStore } from './pageState' @@ -28,6 +30,7 @@ import { createStore, useStore } from './store' import { useChangeNav } from './useChangeNav' import { useDefinitionLinks } from './useDefinitionLinks' import { useDefinitionPeek } from './useDefinitionPeek' +import { useFind } from './useFind' import { useMermaidDiagrams } from './useMermaidDiagrams' import { useOccurrences } from './useOccurrences' import { useQa } from './useQa' @@ -78,11 +81,20 @@ export function App(): ReactElement | null { const restoreCall = useRef(viewState.get().callTree) /** 块树所在的页面内容:钉住卡片时在这里面量 */ const content = useRef(null) - /** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来 */ + /** 查找栏的高亮画在页面内容里 */ + const [findPainter] = useState(() => new FindPainter()) + /** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来;查找的高亮也画在它里面 */ const attachContent = useCallback((el: HTMLDivElement | null) => { content.current = el - return el ? pageLayout.attach(el, document.querySelector('.cr-filter-bar'), () => page.get().placement) : undefined - }, []) + if (!el) + return undefined + const detachLayout = pageLayout.attach(el, document.querySelector('.cr-filter-bar'), () => page.get().placement) + const detachFind = findPainter.attach(el) + return () => { + detachLayout() + detachFind() + } + }, [page, findPainter]) // 这一层重画了:同一段脚本里接着要量位置的(滚到某一行、跳到改动、滚到当前那一处)不能用重画前量的,先作废;放在它们前面 useLayoutEffect(() => pageLayout.invalidate()) const { doc, editing, folds, reveal, excluded, diff, onlyChanges, openCall, changes, collapsed, filter, covered, placement, unshown, stops } = useStore(page, current => current) @@ -108,16 +120,13 @@ export function App(): ReactElement | null { useRepoImages() useMermaidDiagrams() const nav = useChangeNav(stops, collapsed, expand, sticky) + /** 跳不到的块:被筛掉的、正在编辑的、被正在编辑的祖先盖住的;单击高亮和查找栏跳的时候都跳过它们 */ + const skip = useCallback((id: string) => unshown.has(id) || covered.has(id) || editing.includes(id), [unshown, covered, editing]) 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, placement, unshown, covered, editing, collapsed, expand]) + ? { blocks: doc.blocks, placement, skip, collapsed, expand } + : undefined, [doc, placement, skip, collapsed, expand]) const occurrences = useOccurrences(doc?.version, doc?.translatorId, occurrencePage, marks) + const find = useFind(occurrencePage, marks, findPainter) /** 块树:状态是页面 store 里的 tree,动作改页面 store;整页一份,卡片拿到的回调一直是同一个 */ const [tree] = useState((): BlockTreeHandle => ({ @@ -239,14 +248,29 @@ export function App(): ReactElement | null { translators={doc.translators} translatorId={doc.translatorId} onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })} - below={diff && doc.error === undefined && ( - postMessage({ type: 'setOnlyChanges', on: !onlyChanges })} - canMove={stops.length > 0} - onMove={nav.move} - /> + below={( + <> + {diff && doc.error === undefined && ( + postMessage({ type: 'setOnlyChanges', on: !onlyChanges })} + canMove={stops.length > 0} + onMove={nav.move} + /> + )} + {find.open && ( + + )} + )} /> {changes && diff && } diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index ce88d96..478f951 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -22,7 +22,7 @@ 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' +import { foldsByCard, listId, tableId, textKey } from './viewState' /** 块树里改代码的那部分状态:哪些块在编辑。页面上的块树才有;定义卡片浮窗里的块树没有,卡片只读,不画右上角的按钮 */ export interface EditState { @@ -423,7 +423,7 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl const folding = { collapsed: folds, onToggleFold: tree.onToggleFold } return ( <> - {marks?.translation ? : } + {marks?.translation ? : } {block.items && (
@@ -434,7 +434,7 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl
)} - {marks?.doc ? : block.doc && } + {marks?.doc ? : block.doc && } {block.rows && (
diff --git a/vscode/webview-ui/src/codeReader/DiffText.tsx b/vscode/webview-ui/src/codeReader/DiffText.tsx index 0cb1c90..38e0583 100644 --- a/vscode/webview-ui/src/codeReader/DiffText.tsx +++ b/vscode/webview-ui/src/codeReader/DiffText.tsx @@ -23,12 +23,15 @@ export function DiffInline({ parts }: { parts: InlinePart[] }): ReactElement { ) } -/** 预览里一段带增删的文字:着色的词逐截标(DiffInline),Markdown 说明照常排版、增删的字按词 / 字标(同 TextPreview 的排版)。 */ -export function DiffText({ diff, className }: { diff: TextDiff, className?: string }): ReactElement { +/** + * 预览里一段带增删的文字:着色的词逐截标([DiffInline]),Markdown 说明照常排版、增删的字按词 / 字标。 + * 排版和 `find` 同 [TextPreview](vscode/webview-ui/src/codeReader/TextPreview.tsx#TextPreview)。 + */ +export function DiffText({ diff, className, find }: { diff: TextDiff, className?: string, find?: string }): ReactElement { if ('markdown' in diff) - return + return return ( -

+

) diff --git a/vscode/webview-ui/src/codeReader/FindBar.tsx b/vscode/webview-ui/src/codeReader/FindBar.tsx new file mode 100644 index 0000000..059e4bb --- /dev/null +++ b/vscode/webview-ui/src/codeReader/FindBar.tsx @@ -0,0 +1,80 @@ +import type { KeyboardEvent, ReactElement } from 'react' +import { ArrowDown, ArrowUp, Search, X } from 'lucide-react' +import { useLayoutEffect, useRef } from 'react' + +export interface FindBarProps { + query: string + /** 输入框后面写的数(见 [findCount](vscode/webview-ui/src/codeReader/find.ts#findCount));没写字时没有 */ + count?: string + /** 有命中可以跳 */ + canMove: boolean + /** 每变一次就聚焦输入框、选中里面的字(⌘F) */ + focus: number + onQuery: (query: string) => void + /** 跳到上一处(-1)/ 下一处(1)命中 */ + onMove: (step: 1 | -1) => void + onClose: () => void +} + +/** + * 筛选栏下面的查找栏(⌘F 打开):搜索图标、输入框、「第几处/共几处」(没有命中写「无结果」)、上一处 / 下一处、关闭。 + * 输入框里 Enter 跳下一处、Shift+Enter 跳上一处(拼字时的 Enter 不算),栏里哪儿按 Esc 都关掉; + * 这几个键用掉了就拦下(preventDefault),页面上按 Esc 清掉单击高亮的那一下不再跟着动。 + */ +export function FindBar({ query, count, canMove, focus, onQuery, onMove, onClose }: FindBarProps): ReactElement { + const input = useRef(null) + useLayoutEffect(() => { + input.current?.focus() + input.current?.select() + }, [focus]) + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault() + onClose() + } + else if (event.key === 'Enter' && event.target === input.current && !event.nativeEvent.isComposing) { + event.preventDefault() + onMove(event.shiftKey ? -1 : 1) + } + } + return ( +
+ + onQuery(event.target.value)} + /> + {count} + + {([[-1, ArrowUp, '上一处(Shift+Enter)'], [1, ArrowDown, '下一处(Enter)']] as const).map(([step, Icon, label]) => ( + + ))} + + +
+ ) +} diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 9a06a45..623630f 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -8,7 +8,7 @@ import { inlineText } from './segments' import { SegmentText } from './SegmentText' import { subRowsLayout, tableCells, tableTemplate } from './subRows' import { TextPreview } from './TextPreview' -import { propertyItemId, propertyRowId, tableId } from './viewState' +import { propertyItemId, propertyRowId, tableId, textKey } from './viewState' /** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)'] @@ -64,18 +64,18 @@ function CellText({ cell, parts }: { cell: PreviewText, parts: TextDiff | undefi return 'markdown' in cell ? : } -/** 一格连外面那层:着色的词排在 .cr-code 的段落里,Markdown 说明是块,lead(差异记号、展开按钮)排在它左边。 */ -function CellBox({ cell, parts, className, lead, ...attrs }: { cell: PreviewText, parts: TextDiff | undefined, className?: string, lead?: ReactNode, role?: string }): ReactElement { +/** 一格连外面那层:着色的词排在 .cr-code 的段落里,Markdown 说明是块,lead(差异记号、展开按钮)排在它左边。`find` 是这一格的记号(见 [textKey]),记在外面那层上。 */ +function CellBox({ cell, parts, className, lead, find, ...attrs }: { cell: PreviewText, parts: TextDiff | undefined, className?: string, lead?: ReactNode, find?: string, role?: string }): ReactElement { if (isBlock(cell, parts)) { return ( -
+
{lead}
) } return ( -

+

{lead}

@@ -91,12 +91,12 @@ function cellLabel(cell: PreviewText): string { * 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换; * 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。 */ -function ItemHead({ id, cell, foldable, folding }: { id: string, cell: PreviewText, foldable: boolean, folding: Folding }): ReactElement { +function ItemHead({ id, cell, foldable, folding, find }: { id: string, cell: PreviewText, foldable: boolean, folding: Folding, find?: string }): ReactElement { if (!foldable) { return (
- +
) } @@ -116,7 +116,7 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: PreviewTe
- +
) @@ -163,9 +163,12 @@ export function PropertyItems({ parentId, items, folding, marks }: { parentId: s data-diff={change} style={{ gridTemplateColumns: gridTemplate(item.cells.length) }} > - {item.cells.map((cell, i) => i === 0 && nested - ? 0 && change !== 'del'} folding={folds} /> - : )} + {item.cells.map((cell, i) => { + const find = change === 'del' ? undefined : textKey(id, String(i)) + return i === 0 && nested + ? 0 && change !== 'del'} folding={folds} find={find} /> + : + })}
{(open || item.table) && (change === 'del' ?
{below}
: below)} @@ -214,17 +217,18 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle const cells = tableCells(item, columns) const listed = (item.items?.length ?? 0) > 0 const sign = (i: number): ReactNode => signed && i === 0 && {SIGNS[change ?? 'none']} + const find = (i: number): string | undefined => change === 'del' ? undefined : textKey(id, String(i)) /** 一格:讲子块的那一格是展开它的按钮(只放着色的词),别的照常(见 CellBox) */ const cellBox = (cell: PreviewText, i: number, className?: string): ReactElement => toggle && i === 0 ? ( -

+

{sign(i)}

) - : + : const tint = toggle?.open && toggle.badge ? { 'data-badge': '', 'style': { '--kind-dark': toggle.badge.color.dark, '--kind-light': toggle.badge.color.light } as CSSProperties } : {} @@ -233,7 +237,7 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle {cells.map((cell, i) => (listed || item.table) && i === cells.length - 1 ? (
- + {listed && } {item.table && }
@@ -248,7 +252,7 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
- {columns.map((name, i) => {name})} + {columns.map((name, i) => {name})}
{removed(-1)} {items.map((item, index) => { @@ -308,14 +312,15 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl className="cr-prop-label group flex h-5 items-center gap-1 rounded-sm" aria-label={`${folded ? '展开' : '折叠'}${row.label}`} aria-expanded={!folded} + data-find={textKey(id, 'label')} onClick={() => onToggleFold(id)} > {row.label} ) - : {row.label}} - + : {row.label}} +
{!folded && layout === 'table' && } {!folded && layout === 'list' && } @@ -330,16 +335,17 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl /** * 一行的值:着色的词或 Markdown(改了的逐截 / 逐词标);跟着说明(PropertyRow.note,输出参数的 `@return`)时说明接在值后面, - * 同一行放得下就并排,剩的地方不到 12 个字宽时换到下一行。 + * 同一行放得下就并排,剩的地方不到 12 个字宽时换到下一行。`id` 是这一行的 id,值和说明按它记上记号(见 [textKey]);删掉的行没有。 */ -function RowValue({ row, mark }: { row: PropertyRow, mark: RowDiff | undefined }): ReactElement { - const value = mark?.value ? : +function RowValue({ row, mark, id }: { row: PropertyRow, mark: RowDiff | undefined, id?: string }): ReactElement { + const find = (part: string): string | undefined => id === undefined ? undefined : textKey(id, part) + const value = mark?.value ? : if (!row.note) return value return (
{value} - {mark?.note ? : } + {mark?.note ? : }
) } diff --git a/vscode/webview-ui/src/codeReader/TextPreview.tsx b/vscode/webview-ui/src/codeReader/TextPreview.tsx index 9233c3c..89d8f20 100644 --- a/vscode/webview-ui/src/codeReader/TextPreview.tsx +++ b/vscode/webview-ui/src/codeReader/TextPreview.tsx @@ -9,20 +9,23 @@ import { SegmentText } from './SegmentText' /** 卡片里的 Markdown 说明按哪种渲染:页面上是注释;定义卡片浮窗里是 peek(别的文件的注释,不认不带路径的引用)。 */ export const MarkdownKindContext = createContext>('comment') -/** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */ -export const TextPreview = memo(({ text, className }: { text: PreviewText, className?: string }): ReactElement => { +/** + * 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 + * `find` 是卡片上这段字的记号(见 [textKey](vscode/webview-ui/src/codeReader/viewState.ts#textKey)),查找栏按它找到这段字画高亮。 + */ +export const TextPreview = memo(({ text, className, find }: { text: PreviewText, className?: string, find?: string }): ReactElement => { const kind = useContext(MarkdownKindContext) if ('markdown' in text) - return + return return ( -

+

) }) TextPreview.displayName = 'TextPreview' -export function MarkdownPreview({ html, className }: { html: string, className?: string }): ReactElement { +export function MarkdownPreview({ html, className, find }: { html: string, className?: string, find?: string }): ReactElement { const content = useMemo(() => ({ __html: html }), [html]) - return
+ return
} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 9527f01..8d1f5fc 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -397,6 +397,45 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c font-family: var(--vscode-editor-font-family, monospace); } +/* + * 查找栏(FindBar.tsx)的输入框:配色同 VS Code 的输入框,聚焦时描焦点色。 + * 命中画成自定义高亮(findHighlights.ts,不动页面元素),颜色同 VS Code 编辑器的查找:各处命中垫 findMatchHighlightBackground, + * 当前那处垫 findMatchBackground;自定义高亮画不了边框,当前那处的 findMatchBorder(主题给了才有,多是高对比主题)由 .cr-find-outline 盖上去。 + * 和单击名字的高亮([data-occurrence])各画各的,叠在一起时都看得见 + */ +.cr-find-input { + color: var(--vscode-input-foreground); + font: inherit; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, color-mix(in srgb, var(--vscode-foreground) 20%, transparent)); + outline: none; +} +.cr-find-input:focus { + border-color: var(--vscode-focusBorder); +} +.cr-find-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} +::highlight(cr-find) { + background-color: var(--vscode-editor-findMatchHighlightBackground); +} +::highlight(cr-find-current) { + background-color: var(--vscode-editor-findMatchBackground); +} +/* 当前那处外面的框:按文档坐标摆,跟着页面滚;层级在页面内容之上,低于钉住的卡片(4)和筛选栏(10),滚到它们下面时被盖住 */ +.cr-find-outline { + position: absolute; + top: 0; + left: 0; + z-index: 1; + pointer-events: none; +} +.cr-find-outline > div { + position: absolute; + box-sizing: border-box; + border: 1px solid var(--vscode-editor-findMatchBorder, transparent); +} + /* 属性表的属性名:界面字体,和值一样的行高,两边逐行对齐 */ .cr-prop-label { font-size: 12.5px; diff --git a/vscode/webview-ui/src/codeReader/find.test.ts b/vscode/webview-ui/src/codeReader/find.test.ts new file mode 100644 index 0000000..811789b --- /dev/null +++ b/vscode/webview-ui/src/codeReader/find.test.ts @@ -0,0 +1,205 @@ +import type { FindResult, FindUnit } from './find' +import type { WrittenBlock } from './testBlocks' +import type { BlockKind, Inline, Markdown, PropertyRow, Segment } from './types' +import { describe, expect, it } from 'vitest' +import { nameMap } from './displayNames' +import { findCount, findMatches, findUnits, PageFind, paintPlan, shownText, spansOf } from './find' +import { placementOf } from './placement' +import { placed } from './testBlocks' + +function name(text: string, offset: number): Segment { + return { text, role: 'variable', offset } +} + +function word(text: string): Segment { + return { text, role: 'keyword' } +} + +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, 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 } +} + +/** Markdown 显示成的字:测试里就是原文 */ +const markdownText = (markdown: Markdown): string => markdown.markdown + +/** + * 0 def load(path): 函数默认折叠 + * 1 return get_db(path) + * 3 LIMIT = load(3) + */ +const LOAD_ROWS: PropertyRow[] = [ + { label: '名称', value: [name('load', 4)] }, + { label: '输入参数', value: [word('1 个')], items: [{ cells: [[name('path', 9)]] }] }, +] + +const BLOCKS: WrittenBlock[] = [ + factBlock('0', 'function', 0, LOAD_ROWS, [ + textBlock('0.0', 'return', 1, [word('返回'), name('get_db', 30), name('path', 37)]), + ]), + textBlock('1', 'const', 3, [word('设'), name('LIMIT', 50), word('为'), name('load', 58)]), +] + +function unitsOf(blocks: WrittenBlock[], names = nameMap([])): FindUnit[] { + const page = placed(blocks) + return findUnits(page, placementOf(page), names, markdownText) +} + +const ANYWHERE = (): boolean => true + +describe('查找栏:页面上能查的字', () => { + it('整份文件按页面先后排,折叠着的块里的也在;每段记着在哪块、画在哪、要展开什么、块在第几行', () => { + expect(unitsOf(BLOCKS).map(({ block, key, expand, line, text }) => ({ block, key, expand, line, text }))).toEqual([ + { block: '0', key: '0/p0#label', expand: [], line: 0, text: '名称' }, + { block: '0', key: '0/p0#value', expand: [], line: 0, text: 'load' }, + { block: '0', key: '0/p1#label', expand: [], line: 0, text: '输入参数' }, + { block: '0', key: '0/p1#value', expand: [], line: 0, text: '1 个' }, + { block: '0', key: '0/p1/0#0', expand: ['0/p1'], line: 0, text: 'path' }, + { block: '0.0', key: '0.0#text', expand: ['0'], line: 1, text: '返回get_dbpath' }, + { block: '1', key: '1#text', expand: [], line: 3, text: '设LIMIT为load' }, + ]) + }) + + it('按页面上显示的字:起了中文名的名字是中文名(定义处后面还跟着原名),框是框头加框里的字,收起的类名只有小标', () => { + const names = nameMap([{ offset: 58, length: 4, name: '加载' }, { offset: 50, length: 5, name: '上限', definition: true }]) + const shown = shownText([word('设'), name('LIMIT', 50), word('为'), name('load', 58)], 0, names) + expect(shown).toEqual({ text: '设上限LIMIT为加载', renamed: [{ start: 9, end: 11, original: 'load' }] }) + const boxed: Inline[] = [ + word('对'), + { style: 'iteration', label: '每个', segments: [name('item', 3), { style: 'tag', label: '', segments: [word('x')] }] }, + { style: 'classes', label: '样式 3 个类', detail: 'a\nb\nc', segments: [word('a b c')] }, + ] + expect(shownText(boxed, 0, nameMap([])).text).toBe('对每个itemx样式 3 个类') + expect(unitsOf(BLOCKS, names).at(-1)?.text).toBe('设上限LIMIT为加载') + }) + + it('注释、说明的 Markdown 是渲染出来的字;属性名、表头照原样', () => { + const blocks = [factBlock('0', 'class', 0, [ + { label: '说明', value: { markdown: '**加载**配置' } }, + { label: '字段', value: [word('1 个')], columns: ['名称', '类型'], items: [{ cells: [[name('path', 9)], [word('str')]] }] }, + ])] + const page = placed(blocks) + const units = findUnits(page, placementOf(page), nameMap([]), markdown => markdown.markdown.replaceAll('**', '')) + expect(units.map(unit => [unit.key, unit.text])).toEqual([ + ['0/p0#label', '说明'], + ['0/p0#value', '加载配置'], + ['0/p1#label', '字段'], + ['0/p1#value', '1 个'], + ['0/p1#h0', '名称'], + ['0/p1#h1', '类型'], + ['0/p1/0#0', 'path'], + ['0/p1/0#1', 'str'], + ]) + }) +}) + +describe('查找栏:命中', () => { + it('纯文字、不分大小写,一段字里从前往后不重叠地找;特殊字符照字面', () => { + expect(spansOf('ab')('xAbaBab')).toEqual([{ start: 1, end: 3 }, { start: 3, end: 5 }, { start: 5, end: 7 }]) + expect(spansOf('aa')('aaaa')).toEqual([{ start: 0, end: 2 }, { start: 2, end: 4 }]) + expect(spansOf('a.(b')('xa.(b a-(b')).toEqual([{ start: 1, end: 5 }]) + expect(spansOf('')('abc')).toEqual([]) + }) + + it('各段字分开找,不跨段;按页面先后排', () => { + const units = unitsOf(BLOCKS) + expect(findMatches(units, 'LOAD')).toEqual([{ unit: 1, start: 0, end: 4 }, { unit: 6, start: 7, end: 11 }]) + expect(findMatches(units, 'namepath')).toEqual([]) + expect(findMatches(units, 'path').map(match => units[match.unit].key)).toEqual(['0/p1/0#0', '0.0#text']) + }) + + it('显示成中文名的名字,输入源码原名也算命中,命中的是整个中文名;中文名自己也命中时不重复算', () => { + const names = nameMap([{ offset: 58, length: 4, name: '加载' }, { offset: 4, length: 4, name: 'load加载' }]) + const units = unitsOf(BLOCKS, names) + expect(findMatches(units, 'Load')).toEqual([{ unit: 1, start: 0, end: 4 }, { unit: 6, start: 7, end: 9 }]) + expect(findMatches(units, '加载')).toEqual([{ unit: 1, start: 4, end: 6 }, { unit: 6, start: 7, end: 9 }]) + }) + + it('查找栏上的数:没写字时不显示,没有命中写「无结果」,有当前那处写「第几处/共几处」,一处都跳不到时写「N 处」', () => { + const matches = [{ unit: 0, start: 0, end: 1 }, { unit: 1, start: 0, end: 1 }] + expect(findCount({ query: '', matches: [] })).toBeUndefined() + expect(findCount({ query: 'x', matches: [] })).toBe('无结果') + expect(findCount({ query: 'x', matches, current: 1 })).toBe('2/2') + expect(findCount({ query: 'x', matches })).toBe('2 处') + }) + + it('画高亮要的:按段字的记号分好各段的字和命中,当前那处是它那段里的第几个', () => { + const units = unitsOf(BLOCKS) + const result: FindResult = { query: 't', matches: findMatches(units, 't'), current: 2 } + const plan = paintPlan(units, result) + expect(plan.query).toBe('t') + expect(plan.texts.get('0/p1/0#0')).toEqual({ text: 'path', spans: [{ start: 2, end: 3 }] }) + expect(plan.texts.get('0.0#text')).toEqual({ text: '返回get_dbpath', spans: [{ start: 4, end: 5 }, { start: 10, end: 11 }] }) + expect(plan.texts.has('0/p0#value')).toBe(false) + expect(plan.current).toEqual({ key: '0.0#text', index: 1 }) + expect(paintPlan(units, { ...result, current: 1 }).current).toEqual({ key: '0.0#text', index: 0 }) + expect(paintPlan(units, { ...result, current: undefined }).current).toBeUndefined() + }) +}) + +describe('查找栏:当前那处', () => { + function session(units: FindUnit[]): { find: PageFind, results: FindResult[] } { + const results: FindResult[] = [] + const find = new PageFind(result => results.push(result)) + find.setUnits(units, ANYWHERE) + return { find, results } + } + + it('写了字:从给的那处(视口顶部那块的行、选中的字)往后第一处能跳到的是当前那处;被筛掉的块里的跳过,总数照旧', () => { + const units = unitsOf(BLOCKS) + const { find, results } = session(units) + expect(find.search('load', ANYWHERE, { line: 1, start: 0 })).toEqual({ unit: 6, start: 7, end: 11 }) + expect(results.at(-1)).toEqual({ query: 'load', matches: [{ unit: 1, start: 0, end: 4 }, { unit: 6, start: 7, end: 11 }], current: 1 }) + const other = session(units).find + expect(other.search('load', unit => unit.block !== '1', { line: 1, start: 0 })).toEqual({ unit: 1, start: 0, end: 4 }) + expect(other.state.current).toBe(0) + expect(session(units).find.search('load', () => false, { line: 1, start: 0 })).toBeUndefined() + const selected = session(units).find + expect(selected.search('load', ANYWHERE, { key: '0/p0#value', line: 0, start: 1 })).toEqual({ unit: 6, start: 7, end: 11 }) + expect(selected.search('load', ANYWHERE, { key: '0/p0#value', line: 0, start: 0 })).toEqual({ unit: 1, start: 0, end: 4 }) + }) + + it('改了字:从原来那处往后接着找,原来那处还命中就停在原地', () => { + const units = unitsOf(BLOCKS) + const { find } = session(units) + find.search('pat', ANYWHERE) + expect(find.move(1, ANYWHERE)).toEqual({ unit: 5, start: 8, end: 11 }) + expect(find.search('path', ANYWHERE)).toEqual({ unit: 5, start: 8, end: 12 }) + expect(find.search('load', ANYWHERE)).toEqual({ unit: 6, start: 7, end: 11 }) + expect(find.search('', ANYWHERE)).toBeUndefined() + expect(find.state).toEqual({ query: '', matches: [] }) + }) + + it('下一处、上一处按页面先后,到头了从另一头接着;跳不到的跳过;没有命中时不动', () => { + const units = unitsOf(BLOCKS) + const { find } = session(units) + expect(find.move(1, ANYWHERE)).toBeUndefined() + find.search('a', ANYWHERE) + expect(find.state.current).toBe(0) + expect(find.state.matches).toHaveLength(4) + expect(find.move(-1, ANYWHERE)).toBe(find.state.matches[3]) + expect(find.move(1, ANYWHERE)).toBe(find.state.matches[0]) + const shown = (unit: FindUnit): boolean => unit.block !== '0.0' + expect(find.move(1, shown)).toBe(find.state.matches[1]) + expect(find.move(1, shown)).toBe(find.state.matches[3]) + expect(find.state.current).toBe(3) + }) + + it('换了一版文档:照旧按原来的字找,当前那处停在原来那段字里原来的位置往后;那段字没了,按原来那块的行往后找', () => { + const { find, results } = session(unitsOf(BLOCKS)) + find.search('path', ANYWHERE) + find.move(1, ANYWHERE) + const before = results.length + const added = [textBlock('9', 'statement', 0, [word('path')]), ...BLOCKS] + find.setUnits(unitsOf(added), ANYWHERE) + expect(results).toHaveLength(before + 1) + expect(find.state.matches).toHaveLength(3) + expect(find.state.current).toBe(2) + const renumbered = [factBlock('0', 'function', 0, LOAD_ROWS), textBlock('1', 'return', 1, [name('path', 37)])] + find.setUnits(unitsOf(renumbered), ANYWHERE) + expect(find.state.current).toBe(1) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/find.ts b/vscode/webview-ui/src/codeReader/find.ts new file mode 100644 index 0000000..7ee52f9 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/find.ts @@ -0,0 +1,231 @@ +import type { NameMap } from './displayNames' +import type { Placement } from './placement' +import type { BlockView, Inline, Markdown } from './types' +import { shownName } from './displayNames' +import { occurrenceCount, stepIndex } from './occurrences' +import { cardTexts, placedBlocks } from './reveal' + +/** 一段字里的 [start, end)。 */ +export interface Span { + start: number + end: number +} + +/** 显示成中文名的一段:中文名在那段字里占的地方,源码里的原名。 */ +export interface Renamed extends Span { + original: string +} + +/** + * 页面上能查的一段字(卡片上的一段,见 [cardTexts]):在哪块的卡片上,画在记号是 `key` 的元素上, + * `expand` 是要让它露出来得展开的块、属性行和子行(从顶层往下),`line` 是那块在文件里的第一行,`text` 是页面上显示的字。 + */ +export interface FindUnit { + block: string + key: string + expand: string[] + line: number + text: string + renamed: Renamed[] +} + +/** 一处命中:第几段字([FindUnit] 的序号)里的 [start, end)。 */ +export interface FindMatch extends Span { + unit: number +} + +/** 查找栏眼下的结果:查的字,按页面先后排的各处命中,当前那处的序号(一处都跳不到时没有)。 */ +export interface FindResult { + query: string + matches: readonly FindMatch[] + current?: number +} + +const NOTHING: FindResult = { query: '', matches: [] } + +/** + * 着色的词在页面上显示的字,同 [SegmentText](vscode/webview-ui/src/codeReader/SegmentText.tsx#SegmentText) 画的: + * 起了中文名的名字是中文名(定义处后面还跟着原名),框是框头加框里的字,收起的类名只有小标。 + * 只显示中文名的那几段记进 `renamed`,查原名时也算命中它。`base` 是所在那块的开头在文件里的下标,没有时不换中文名。 + */ +export function shownText(pieces: readonly Inline[], base: number | undefined, names: NameMap): { text: string, renamed: Renamed[] } { + let text = '' + const renamed: Renamed[] = [] + const add = (list: readonly Inline[]): void => { + for (const piece of list) { + if (!('role' in piece)) { + text += piece.label + if (piece.style !== 'classes') + add(piece.segments) + continue + } + const shown = base === undefined ? undefined : shownName(piece, base, names) + if (shown && !shown.definition) + renamed.push({ start: text.length, end: text.length + shown.name.length, original: piece.text }) + text += shown ? `${shown.name}${shown.definition ? piece.text : ''}` : piece.text + } + } + add(pieces) + return { text, renamed } +} + +/** + * 整份文件里能查的字,按页面先后:每块(折叠着的、被筛掉的也算)卡片上的各段字。 + * Markdown 显示成什么字由 `markdownText` 给(渲染出来的字,见 [markdownText](vscode/webview-ui/src/codeReader/findHighlights.ts#markdownText))。 + */ +export function findUnits(blocks: BlockView[], placement: Placement, names: NameMap, markdownText: (markdown: Markdown) => string): FindUnit[] { + const units: FindUnit[] = [] + for (const { block, ancestors } of placedBlocks(blocks)) { + const place = placement.of(block.id) + for (const { key, text, expand } of cardTexts(block)) { + const shown = typeof text === 'string' + ? { text, renamed: [] } + : 'markdown' in text ? { text: markdownText(text), renamed: [] } : shownText(text, place?.offset, names) + units.push({ block: block.id, key, expand: [...ancestors, ...expand], line: place?.line ?? 0, ...shown }) + } + } + return units +} + +/** 在一段字里按字面、不分大小写找 `query`:从前往后,各处不重叠。`query` 是空的时一处都没有。 */ +export function spansOf(query: string): (text: string) => Span[] { + if (query === '') + return () => [] + const pattern = new RegExp(query.replace(/[\\^$.*+?()[\]{}|/]/g, '\\$&'), 'giu') + return text => Array.from(text.matchAll(pattern), match => ({ start: match.index, end: match.index + match[0].length })) +} + +/** 各段字里的命中,按页面先后排。只显示中文名的那段,原名命中而中文名没命中时整个中文名算一处。 */ +export function findMatches(units: readonly FindUnit[], query: string): FindMatch[] { + const find = spansOf(query) + const matches: FindMatch[] = [] + units.forEach((unit, index) => { + const spans = find(unit.text) + for (const renamed of unit.renamed) { + if (find(renamed.original).length > 0 && !spans.some(span => span.start < renamed.end && renamed.start < span.end)) + spans.push({ start: renamed.start, end: renamed.end }) + } + spans.sort((a, b) => a.start - b.start) + for (const span of spans) + matches.push({ unit: index, ...span }) + }) + return matches +} + +/** 查找栏上输入框后面写的:没写字时不写,没有命中写「无结果」,别的同筛选栏上名字后面写的(见 [occurrenceCount])。 */ +export function findCount(result: FindResult): string | undefined { + if (result.query === '') + return undefined + return result.matches.length === 0 ? '无结果' : occurrenceCount({ occurrences: result.matches, ...(result.current !== undefined && { current: result.current }) }) +} + +/** 往页面上画高亮要的:查的字,有命中的各段字(按记号)显示的字和命中,当前那处在哪段字里、是那段里的第几处。 */ +export interface FindPaint { + query: string + texts: ReadonlyMap + current?: { key: string, index: number } +} + +export function paintPlan(units: readonly FindUnit[], result: FindResult): FindPaint { + const texts = new Map() + let current: FindPaint['current'] + result.matches.forEach((match, index) => { + const { key, text } = units[match.unit] + const entry = texts.get(key) ?? { text, spans: [] } + texts.set(key, entry) + if (index === result.current) + current = { key, index: entry.spans.length } + entry.spans.push({ start: match.start, end: match.end }) + }) + return { query: result.query, texts, ...(current && { current }) } +} + +/** + * 从哪往后找当前那处:一段字(记号 `key`)里第 `start` 个字(原来那处、选中的字),那段字没有了从 `line` 这一行往后第一段字起; + * 不给 `key` 时只看行(视口顶部那块的行)。 + */ +export interface FindAnchor { + key?: string + start: number + line: number +} + +/** + * 查找栏里的查找:查的字、各处命中和当前那处,变了交给 `changed`。 + * 当前那处总是从某处往后第一处能跳到的(`reachable` 说那段字所在的块画没画出来):写了字从原来那处往后(原来没有就从视口顶部那块往后), + * 换了一版文档从原来那处往后,原来那处还命中就停在原地;被筛掉的块里的跳过,但算在总数里。 + */ +export class PageFind { + private units: readonly FindUnit[] = [] + private result = NOTHING + + constructor(private readonly changed: (result: FindResult) => void) {} + + get state(): FindResult { + return this.result + } + + unitOf(match: FindMatch): FindUnit { + return this.units[match.unit] + } + + /** 眼下往页面上画什么(见 [paintPlan]) */ + paint(): FindPaint { + return paintPlan(this.units, this.result) + } + + /** 页面上能查的字换了(新一版文档、中文名到了、打开了查找栏):照旧按原来的字找,当前那处停在原来那处附近。 */ + setUnits(units: readonly FindUnit[], reachable: (unit: FindUnit) => boolean): void { + const anchor = this.anchor() + this.units = units + if (this.result.query !== '') + this.show(this.found(this.result.query, anchor ?? { start: 0, line: 0 }, reachable)) + } + + /** 查的字换了:从 `from`(选中的字)往后找,没给时从原来那处往后,原来也没有当前那处时从文件开头。返回当前那处。 */ + search(query: string, reachable: (unit: FindUnit) => boolean, from?: FindAnchor): FindMatch | undefined { + this.show(this.found(query, from ?? this.anchor() ?? { start: 0, line: 0 }, reachable)) + return this.currentMatch() + } + + /** 当前那处换成往后(`step` 1)/ 往前(`step` -1)下一处能跳到的,到头了从另一头接着;返回它,一处都跳不到时不动、返回 `undefined`。 */ + move(step: 1 | -1, reachable: (unit: FindUnit) => boolean): FindMatch | undefined { + const { matches, current } = this.result + const index = stepIndex(matches, current ?? (step === 1 ? -1 : matches.length), 0, step, match => reachable(this.units[match.unit])) + if (index === undefined) + return undefined + this.show({ ...this.result, current: index }) + return matches[index] + } + + private currentMatch(): FindMatch | undefined { + const { matches, current } = this.result + return current === undefined ? undefined : matches[current] + } + + private anchor(): FindAnchor | undefined { + const match = this.currentMatch() + const unit = match && this.units[match.unit] + return unit && { key: unit.key, start: match.start, line: unit.line } + } + + /** 找 `query`,当前那处是从 `anchor` 往后第一处能跳到的:那段字还在就从它里面那个位置往后,没了从那一行往后第一段字起。 */ + private found(query: string, anchor: FindAnchor, reachable: (unit: FindUnit) => boolean): FindResult { + if (query === '') + return NOTHING + const matches = findMatches(this.units, query) + const own = anchor.key === undefined ? -1 : this.units.findIndex(unit => unit.key === anchor.key) + const unit = own >= 0 ? own : this.units.findIndex(unit => unit.line >= anchor.line) + const start = own >= 0 ? anchor.start : 0 + const first = unit < 0 ? matches.length : matches.findIndex(match => match.unit > unit || (match.unit === unit && match.start >= start)) + const current = stepIndex(matches, (first < 0 ? matches.length : first) - 1, 0, 1, match => reachable(this.units[match.unit])) + return { query, matches, ...(current !== undefined && { current }) } + } + + private show(result: FindResult): void { + if (result === this.result) + return + this.result = result + this.changed(result) + } +} diff --git a/vscode/webview-ui/src/codeReader/findHighlights.test.tsx b/vscode/webview-ui/src/codeReader/findHighlights.test.tsx new file mode 100644 index 0000000..1f60331 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/findHighlights.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom + +import type { BlockTreeHandle } from './BlockTree' +import type { TextMarks } from './SegmentText' +import type { BlockView, PropertyRow, TextDiff } from './types' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BlockTree, treeState } from './BlockTree' +import { DiffText } from './DiffText' +import { nameMap } from './displayNames' +import { findMatches, findUnits, paintPlan } from './find' +import { FindPainter, markdownText, textOf } from './findHighlights' +import { placementOf } from './placement' +import { TextMarksContext } from './SegmentText' +import { staticStore } from './store' +import { placed } from './testBlocks' + +vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) + +function element(html: string): HTMLElement { + const el = document.createElement('div') + el.innerHTML = html + document.body.append(el) + return el +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('页面上一段字显示的字', () => { + it('各个文字节点连起来;成块的元素前后断开(换行),差异里删掉的字、记号、收起的类名弹层、图标不算', () => { + const el = element([ + '

+设图每个x', + '旧新', + 'a b

', + '

第一段删加

  • 一
  • 二
', + ].join('')) + const { text, nodes } = textOf(el) + expect(text).toBe('设每个x新样式\n第一段加\n一\n二') + expect(nodes.map(node => [node.node.data, node.start])).toEqual([['设', 0], ['每个', 1], ['x', 3], ['新', 4], ['样式', 5], ['第一段', 8], ['加', 11], ['一', 13], ['二', 15]]) + }) + + it('markdown 说明显示的字:同一份渲染出来的 HTML 在页面上读出来的一样;同一段说明只渲染一次', () => { + const doc = { markdown: '# 标题\n\n**加载**配置,见 `load`:\n\n- 一\n- 二\n\n```py\nx = 1\n```' } + const text = markdownText(doc) + expect(text).toContain('加载配置,见 load:') + expect(text).not.toContain('**') + expect(markdownText(doc)).toBe(text) + const page = element(renderToStaticMarkup()) + expect(textOf(page.firstElementChild!).text).toBe(text) + }) +}) + +/** 一张属性表卡片、一块一行译文(带拆开的项、实参表、说明、几行属性),一张挂着子块的卡片 */ +const ROWS: PropertyRow[] = [ + { label: '名称', value: [{ text: 'load', role: 'function', offset: 4 }] }, + { label: '说明', value: { markdown: '**加载**配置\n\n- 一\n- 二' } }, + { label: '输出', value: [{ text: 'str', role: 'type' }], note: { markdown: '返回 `path`' } }, + { label: '输入参数', value: [{ text: '2 个', role: 'hint' }], columns: ['名称', '默认值'], items: [ + { cells: [[{ text: 'path', role: 'variable', offset: 9 }], [{ text: '无', role: 'hint' }], [{ text: '多给的', role: 'plain' }]] }, + { cells: [[{ text: 'opts', role: 'variable', offset: 15 }], { markdown: '`{}`' }], items: [{ cells: [[{ text: 'k', role: 'plain' }], [{ text: 'v', role: 'plain' }]], items: [{ cells: [[{ text: '深', role: 'plain' }]] }] }], table: { columns: ['实参'], items: [{ cells: [[{ text: 'z', role: 'plain' }]] }] } }, + ] }, + { label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[{ text: 'a', role: 'plain' }], [{ text: 'b', role: 'plain' }]], items: [{ cells: [[{ text: 'c', role: 'plain' }]] }] }] }, +] + +const BLOCKS: BlockView[] = placed([ + { id: '0', anchor: '0', kind: 'function', attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'box' }], properties: ROWS, children: [ + { + id: '0.0', + anchor: '0.0', + kind: 'statement', + attributes: { kind: 'statement' }, + concepts: [{ id: 'statement', label: '语句', icon: 'box' }], + children: [], + translation: [ + { text: '设', role: 'keyword' }, + { text: 'LIMIT', role: 'constant', offset: 40 }, + { style: 'iteration', label: '对每个', segments: [{ text: 'item', role: 'variable', offset: 50 }] }, + { style: 'classes', label: '样式 2 个类', detail: 'a\nb', segments: [{ text: 'a b', role: 'string' }] }, + { text: 'load', role: 'function', offset: 4 }, + ], + items: [{ cells: [[{ text: 'key', role: 'string' }], [{ text: 'value', role: 'string' }]] }], + table: { columns: ['参数', '值'], items: [{ cells: [[{ text: 'id', role: 'variable' }], [{ text: '1', role: 'number' }]] }] }, + doc: { markdown: '说明 *文字*' }, + rows: [{ label: '注释', value: { markdown: '备注' } }], + }, + ] }, + { id: '1', anchor: '1', kind: 'demo.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, attributes: { kind: 'demo.card' }, concepts: [{ id: 'demo.card', label: '卡片', icon: 'box' }], properties: [ + { label: '方法', value: [{ text: '1 个', role: 'hint' }], columns: ['名称'], items: [{ cells: [[{ text: 'start', role: 'function' }]] }] }, + ], children: [ + { id: '1.0', anchor: '1.0', kind: 'demo.method', slot: { row: 0, item: 0 }, attributes: { kind: 'demo.method' }, concepts: [{ id: 'demo.method', label: '方法', icon: 'box' }], children: [], translation: [{ text: '启动服务', role: 'plain' }] }, + ] }, +]) + +const NAMES = nameMap([{ offset: 4, length: 4, name: '加载' }, { offset: 40, length: 5, name: '上限', definition: true }]) + +/** 把块树画出来:什么都没折叠,中文名是 NAMES */ +function renderPage(): HTMLElement { + const tree: BlockTreeHandle = { + state: staticStore(treeState({ collapsed: new Set(), hidden: new Set(), temporary: new Set(), covered: new Map() })), + onToggleFold: () => {}, + onSetFolds: () => {}, + onOpenRun: () => {}, + } + const marks = staticStore({ names: NAMES, placement: placementOf(BLOCKS) }) + return element(renderToStaticMarkup( + + + , + )) +} + +describe('页面上画的字和算命中用的字对得上', () => { + it('每段能查的字在页面上正好一个元素记着它,读出来的字和算的一样(属性名、值、说明、表头、各层子行、译文、框、说明、几行属性、挂在子行上的子块)', () => { + const root = renderPage() + const units = findUnits(BLOCKS, placementOf(BLOCKS), NAMES, markdownText) + expect(units.length).toBeGreaterThan(30) + for (const unit of units) { + const found = root.querySelectorAll(`[data-find="${unit.key}"]`) + expect(found, unit.key).toHaveLength(1) + expect(textOf(found[0]).text, unit.key).toBe(unit.text) + } + expect(root.querySelectorAll('[data-find]')).toHaveLength(units.length) + }) +}) + +describe('往页面上画查找的高亮', () => { + it('各段字按算好的命中画;当前那处是它那段里的第几处;没有命中的段、浮窗这类根外面的不画', () => { + const root = renderPage() + const outside = element('

名称

') + const units = findUnits(BLOCKS, placementOf(BLOCKS), NAMES, markdownText) + const matches = findMatches(units, 'load') + const painter = new FindPainter() + const detach = painter.attach(root) + const painted = painter.show(paintPlan(units, { query: 'load', matches, current: 1 })) + expect(painted.ranges.map(range => range.toString())).toEqual(['加载', '加载']) + expect(painted.current?.toString()).toBe('加载') + expect(painted.current?.startContainer.parentElement?.closest('[data-find]')?.getAttribute('data-find')).toBe('0.0#text') + expect(painted.text?.getAttribute('data-find')).toBe('0.0#text') + expect(outside.contains(painted.ranges[0].startContainer)).toBe(false) + + const all = painter.show(paintPlan(units, { query: '名称', matches: findMatches(units, '名称'), current: 2 })) + expect(all.ranges.map(range => range.startContainer.parentElement?.closest('[data-find]')?.getAttribute('data-find'))).toEqual(['0/p0#label', '0/p3#h0', '1/p0#h0']) + expect(all.current?.startContainer.parentElement?.closest('[data-find]')?.getAttribute('data-find')).toBe('1/p0#h0') + detach() + }) + + it('页面上的字和算的对不上(差异里的旧字、画成图的说明):按页面上的字现找,当前那处取那段里同样第几处,没有就落在那段字上', () => { + const root = element('

old new old

gone

') + const painter = new FindPainter() + painter.attach(root) + const plan = { query: 'old', texts: new Map([['a#value', { text: 'new old', spans: [{ start: 4, end: 7 }] }], ['b#value', { text: 'old', spans: [{ start: 0, end: 3 }] }]]) } + const painted = painter.show({ ...plan, current: { key: 'a#value', index: 0 } }) + expect(painted.ranges.map(range => [range.startOffset, range.endOffset])).toEqual([[0, 3], [8, 11]]) + expect(painted.current?.startOffset).toBe(0) + const missing = painter.show({ ...plan, current: { key: 'b#value', index: 0 } }) + expect(missing.current).toBeUndefined() + expect(missing.text?.textContent).toBe('gone') + expect(painter.show(undefined)).toEqual({ ranges: [] }) + }) + + it('页面变了(展开了、换了一版文档):下一帧照旧按要画的重画', async () => { + const root = element('

x

') + const painter = new FindPainter() + const detach = painter.attach(root) + painter.show({ query: 'x', texts: new Map([['b#value', { text: 'x', spans: [{ start: 0, end: 1 }] }]]), current: { key: 'b#value', index: 0 } }) + expect(painter.painted.current).toBeUndefined() + root.insertAdjacentHTML('beforeend', '

x

') + await vi.waitFor(() => expect(painter.painted.current?.toString()).toBe('x')) + detach() + }) +}) + +describe('差异里改了的字', () => { + it('删掉的旧字不算,读出来的是新字', () => { + const diff: TextDiff = { inline: [{ segments: [{ text: 'a', role: 'plain' }] }, { segments: [{ text: 'b', role: 'plain' }], change: 'del' }, { segments: [{ text: 'c', role: 'plain' }], change: 'add' }] } + const el = element(renderToStaticMarkup()) + expect(el.querySelector('[data-find="x#text"]')).not.toBeNull() + expect(textOf(el).text).toBe('ac') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/findHighlights.ts b/vscode/webview-ui/src/codeReader/findHighlights.ts new file mode 100644 index 0000000..ffc2e5a --- /dev/null +++ b/vscode/webview-ui/src/codeReader/findHighlights.ts @@ -0,0 +1,247 @@ +import type { FindPaint, Span } from './find' +import type { Markdown } from './types' +import { spansOf } from './find' +import { renderMarkdown } from './markdown' + +/** 读字时跳过的:差异里删掉的字和行首的 + / − 记号,点开的类名弹层,图标和画出来的图(svg) */ +const SKIPPED = '.cr-diff-sign, .cr-diff-part[data-change="del"], del.cr-diff-word, .cr-classes-popup, svg' + +/** 成块排的元素:前后的字不连成一串,中间算一个换行(段落、列表项、标题、代码块、表格的格子) */ +const BLOCK_TAGS = new Set([ + 'ADDRESS', + 'ARTICLE', + 'ASIDE', + 'BLOCKQUOTE', + 'BR', + 'DD', + 'DIV', + 'DL', + 'DT', + 'FIGCAPTION', + 'FIGURE', + 'FOOTER', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'HEADER', + 'HR', + 'LI', + 'MAIN', + 'NAV', + 'OL', + 'P', + 'PRE', + 'SECTION', + 'TABLE', + 'TBODY', + 'TD', + 'TFOOT', + 'TH', + 'THEAD', + 'TR', + 'UL', +]) + +/** 一段字在页面上读出来的样子:显示的字,和各个文字节点从字里第几个字起。 */ +export interface PageText { + text: string + nodes: { node: Text, start: number }[] +} + +/** + * `root` 里显示的字:各个文字节点按先后连起来,成块的元素之间算一个换行(查的字是一行,不会跨块命中),[SKIPPED] 里的不算。 + * 页面上的一段字和算命中用的 Markdown 说明([markdownText])都按它读,同一份 HTML 读出来一样。 + */ +export function textOf(root: Node): PageText { + let text = '' + const nodes: PageText['nodes'] = [] + let broken = false + const visit = (parent: Node): void => { + for (let child = parent.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.TEXT_NODE) { + const value = (child as Text).data + if (value === '') + continue + if (broken && text !== '') + text += '\n' + broken = false + nodes.push({ node: child as Text, start: text.length }) + text += value + } + else if (child.nodeType === Node.ELEMENT_NODE && !(child as Element).matches(SKIPPED)) { + const block = BLOCK_TAGS.has((child as Element).tagName) + broken ||= block + visit(child) + broken ||= block + } + } + } + visit(root) + return { text, nodes } +} + +const markdownTexts = new WeakMap() + +/** 一段 Markdown 说明在页面上显示的字:同页面上一样渲染成 HTML([renderMarkdown])再读([textOf]);同一段说明只读一次。 */ +export function markdownText(markdown: Markdown): string { + let text = markdownTexts.get(markdown) + if (text === undefined) { + const template = document.createElement('template') + template.innerHTML = renderMarkdown(markdown.markdown) + text = textOf(template.content).text + markdownTexts.set(markdown, text) + } + return text +} + +/** 一段字里 `span` 那几个字在页面上的范围;落不到文字节点上时没有。 */ +function rangeIn({ nodes }: PageText, { start, end }: Span): Range | undefined { + let from: PageText['nodes'][number] | undefined + let to: PageText['nodes'][number] | undefined + for (const node of nodes) { + if (node.start <= start) + from = node + if (node.start < end) + to = node + } + if (!from || !to) + return undefined + const range = document.createRange() + range.setStart(from.node, start - from.start) + range.setEnd(to.node, end - to.start) + return range +} + +/** 画好的查找高亮:各处命中,当前那处,当前那处所在的那段字(当前那处画不出来时滚到它)。 */ +export interface PaintedFind { + ranges: Range[] + current?: Range + text?: HTMLElement +} + +const NOT_PAINTED: PaintedFind = { ranges: [] } + +/** CSS 里给查找高亮起的名字(见 codeReader.css 的 ::highlight):各处命中,当前那处 */ +const ALL = 'cr-find' +const CURRENT = 'cr-find-current' + +/** + * 把查找栏算好的命中画到页面上:用 CSS 的自定义高亮(`CSS.highlights`)给各处命中的字垫底色,不动页面的元素,块树不用重画; + * 当前那处另垫一层,再在它外面描一圈框(自定义高亮画不了边框,框是盖在页面上的几个空元素,见 [place])。 + * 只画块树所在的那层页面内容([attach] 的 `root`)里记着字的元素(`data-find`),定义卡片浮窗、钉住的卡片头在它外面,不画。 + * 页面上的字和算的一样时按算好的位置画;对不上(差异里的旧字、画成图的说明)时按页面上的字现找。 + * 展开、换了一版文档这些让页面变了的,下一帧照旧按要画的重画。 + */ +export class FindPainter { + private container: HTMLElement | null = null + private plan: FindPaint | undefined + private result = NOT_PAINTED + private frame = 0 + private outline: HTMLElement | undefined + + get painted(): PaintedFind { + return this.result + } + + /** 画在哪层页面内容里(见 [attach]);还没挂上时没有 */ + get root(): HTMLElement | null { + return this.container + } + + /** 画在 `root` 里;返回停止画的函数 */ + attach(root: HTMLElement): () => void { + this.container = root + const later = (): void => this.later() + const mutations = new MutationObserver(later) + mutations.observe(root, { childList: true, subtree: true, characterData: true }) + const sizes = new ResizeObserver(later) + sizes.observe(root) + window.addEventListener('resize', later) + this.paint() + return () => { + mutations.disconnect() + sizes.disconnect() + window.removeEventListener('resize', later) + cancelAnimationFrame(this.frame) + if (this.container === root) { + this.container = null + this.paint() + } + } + } + + /** 换成要画的(没有就清掉),马上画,交回画好的。 */ + show(plan: FindPaint | undefined): PaintedFind { + this.plan = plan + this.paint() + return this.result + } + + private later(): void { + if (!this.plan) + return + cancelAnimationFrame(this.frame) + this.frame = requestAnimationFrame(() => this.paint()) + } + + private paint(): void { + const { container: root, plan } = this + this.result = root && plan && plan.texts.size > 0 ? collect(root, plan) : NOT_PAINTED + const registry = typeof CSS !== 'undefined' ? CSS.highlights as HighlightRegistry | undefined : undefined + if (registry) { + const current = new Highlight(...(this.result.current ? [this.result.current] : [])) + current.priority = 1 + registry.set(ALL, new Highlight(...this.result.ranges)) + registry.set(CURRENT, current) + } + this.place() + } + + /** + * 摆当前那处外面的框:每行一个,按文档坐标摆,跟着页面滚动;层级低于钉住的卡片和筛选栏,滚到它们下面时被盖住。 + * 滚过去之前那一带要先排版(见 [showInViewport](vscode/webview-ui/src/codeReader/useReveal.ts#showInViewport)),位置可能变了,滚完再摆一次。 + */ + place(): void { + const rects = this.result.current?.getClientRects?.() ?? [] + if (rects.length === 0) { + this.outline?.replaceChildren() + return + } + if (!this.outline?.isConnected) { + this.outline = document.createElement('div') + this.outline.className = 'cr-find-outline' + document.body.append(this.outline) + } + this.outline.replaceChildren(...Array.from(rects, (rect) => { + const box = document.createElement('div') + box.style.cssText = `left:${rect.left + window.scrollX}px;top:${rect.top + window.scrollY}px;width:${rect.width}px;height:${rect.height}px` + return box + })) + } +} + +/** 按要画的读出 `root` 里各段字的命中:同一个记号有几个元素时当前那处取第一个。 */ +function collect(root: HTMLElement, plan: FindPaint): PaintedFind { + const ranges: Range[] = [] + let current: Range | undefined + let text: HTMLElement | undefined + const find = spansOf(plan.query) + for (const el of root.querySelectorAll('[data-find]')) { + const key = el.dataset.find ?? '' + const expected = plan.texts.get(key) + if (!expected) + continue + const page = textOf(el) + const spans = page.text === expected.text ? expected.spans : find(page.text) + const found = spans.flatMap(span => rangeIn(page, span) ?? []) + ranges.push(...found) + if (plan.current?.key === key && !text) { + text = el + current = found[Math.min(plan.current.index, found.length - 1)] + } + } + return { ranges, ...(current && { current }), ...(text && { text }) } +} diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index b47ffbc..d564da3 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -133,8 +133,8 @@ export function isCurrent(segment: Segment, base: number, highlight: Highlight): return current !== undefined && overlaps(segment, base, current) } -/** 筛选栏上名字后面写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */ -export function occurrenceCount(highlight: Highlight): string { +/** 筛选栏上名字后面、查找栏上写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */ +export function occurrenceCount(highlight: { occurrences: readonly unknown[], current?: number }): string { const total = highlight.occurrences.length return highlight.current === undefined ? `${total} 处` : `${highlight.current + 1}/${total}` } @@ -143,12 +143,12 @@ export function occurrenceCount(highlight: Highlight): string { * 往后(step 1)/ 往前(step -1)数,下一处能跳到的出现的序号,到头了从另一头接着数;一处都跳不到时没有。 * 从当前那一处(current)数起;没有当前那一处时从源码下标 from(点中的名字)数起。 */ -export function stepIndex( - occurrences: readonly Occurrence[], +export function stepIndex( + occurrences: readonly T[], current: number | undefined, from: number, step: 1 | -1, - reachable: (occurrence: Occurrence) => boolean, + reachable: (occurrence: T) => boolean, ): number | undefined { const count = occurrences.length // 没有当前那一处时,起点是 from 前面最后一处(往后数)/ 后面第一处(往前数),数的时候跳过它本身所在的位置 @@ -332,6 +332,34 @@ export class OccurrenceHighlight { } } +/** + * 页面上一组快捷键眼下能不能用:要看的几件事(facts)记着,`test` 按它们算能不能用;变了才交给 `report` 告诉扩展端, + * 扩展端跟着设快捷键 when 子句用的上下文键。第一次 update 时不管变没变都告诉(页面重新加载后扩展端记的可能是旧页面的)。 + */ +export class ShortcutContext { + private reported: boolean | undefined + + constructor( + private facts: Facts, + private readonly test: (facts: Facts) => boolean, + private readonly report: (available: boolean) => void, + ) {} + + get available(): boolean { + return this.test(this.facts) + } + + /** 改了哪几件事 */ + update(change: Partial): void { + this.facts = { ...this.facts, ...change } + const { available } = this + if (available === this.reported) + return + this.reported = available + this.report(available) + } +} + /** 能不能用键盘跳要看的几件事。 */ export interface NavigationFacts { /** 有名字高亮着 */ @@ -346,30 +374,12 @@ export interface NavigationFacts { * 眼下能不能用键盘在高亮着的名字的各处出现之间跳:有高亮、页面有焦点、焦点不在输入框里。变了才告诉扩展端, * 扩展端跟着设快捷键 when 子句用的上下文键;页面也按它决定要不要拦下这些键在浏览器里的默认动作(滚到顶、滚到底)。 */ -export class OccurrenceNavigation { - private facts: NavigationFacts - private reported: boolean | undefined - - constructor(private readonly post: (msg: WebviewToExtension) => void, initial: Omit) { - this.facts = { highlighted: false, ...initial } - } - - get available(): boolean { - const { highlighted, focused, typing } = this.facts - return highlighted && focused && !typing - } - - /** 改了哪几件事;第一次调用时不管变没变都告诉扩展端(页面重新加载后扩展端记的可能是旧页面的) */ - update(change: Partial): void { - this.facts = { ...this.facts, ...change } - this.report() - } - - private report(): void { - const { available } = this - if (available === this.reported) - return - this.reported = available - this.post({ type: 'occurrenceNavigation', available }) +export class OccurrenceNavigation extends ShortcutContext { + constructor(post: (msg: WebviewToExtension) => void, initial: Omit) { + super( + { highlighted: false, ...initial }, + ({ highlighted, focused, typing }) => highlighted && focused && !typing, + available => post({ type: 'occurrenceNavigation', available }), + ) } } diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index 694142b..9dec5ed 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -2,7 +2,7 @@ import type { BlockView as HostBlockView } from '../../../src/codeReader/blockVi import type { LanguageAdapter, LibraryRule, Translator } from '../../../src/codeReader/contract' import type { RevealPlan } from './reveal' import type { WrittenBlock } from './testBlocks' -import type { BlockKind, Inline, Occurrence, PropertyItem, PropertyRow, Segment } from './types' +import type { BlockKind, CardView, Inline, Occurrence, PreviewText, PropertyItem, PropertyRow, Segment } from './types' import { describe, expect, it } from 'vitest' import { toBlockViews } from '../../../src/codeReader/blockView' import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks' @@ -14,7 +14,7 @@ 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 { cardTexts, occurrencePlan, revealPlan, scrollTopToShow } from './reveal' import { placed } from './testBlocks' import { collapsedIds, propertyItemId, propertyRowId } from './viewState' @@ -114,6 +114,85 @@ describe('用键盘跳到高亮的一处出现:滚到哪块、展开哪些', ( }) }) +describe('卡片上的各段字:页面上的先后、画在哪、要展开的', () => { + const texts = (block: CardView): [string, PreviewText | string, string[]][] => [...cardTexts(block)].map(({ key, text, expand }) => [key, text, expand]) + + it('属性表:每行的属性名、值、说明,再是这一行下面的子行;列表的项下面的下一层要展开那一项', () => { + const [imports] = placed(BLOCKS) + expect(texts(imports)).toEqual([ + ['0/p0#label', '来源', []], + ['0/p0#value', [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)], []], + ['0/p1#label', '导入项', []], + ['0/p1#value', [{ text: '1 个', role: 'hint' }], []], + ['0/p1/0#0', [name('get_db', 34)], ['0/p1']], + ]) + const nested = factBlock('3', 'variable', 9, [{ + label: '值', + value: { markdown: '字典' }, + note: { markdown: '说明' }, + items: [{ cells: [[name('a', 1)], [name('b', 3)]], items: [{ cells: [[name('c', 5)]] }] }], + }]) + expect(texts(placed([nested])[0])).toEqual([ + ['3/p0#label', '值', []], + ['3/p0#value', { markdown: '字典' }, []], + ['3/p0#note', { markdown: '说明' }, []], + ['3/p0/0#0', [name('a', 1)], ['3/p0']], + ['3/p0/0#1', [name('b', 3)], ['3/p0']], + ['3/p0/0/0#0', [name('c', 5)], ['3/p0', '3/p0/0']], + ]) + }) + + it('表格:表头、各行按列名取的格子;行下面拆开的列表、套着的实参表总是展开的,列表的项下面的下一层要展开那一项', () => { + const table = factBlock('1', 'function', 2, [{ + label: '输入参数', + value: [{ text: '1 个', role: 'hint' }], + columns: ['名称', '默认值'], + items: [{ + cells: [[name('path', 51)], [word('无')], [word('多给的')]], + items: [{ cells: [[name('x', 60)]], items: [{ cells: [[name('y', 62)]] }] }], + table: { columns: ['实参'], items: [{ cells: [[name('z', 64)]] }] }, + }], + }]) + expect(texts(placed([table])[0])).toEqual([ + ['1/p0#label', '输入参数', []], + ['1/p0#value', [{ text: '1 个', role: 'hint' }], []], + ['1/p0#h0', '名称', ['1/p0']], + ['1/p0#h1', '默认值', ['1/p0']], + ['1/p0/0#0', [name('path', 51)], ['1/p0']], + ['1/p0/0#1', [word('无')], ['1/p0']], + ['1/p0/0/0#0', [name('x', 60)], ['1/p0']], + ['1/p0/0/0/0#0', [name('y', 62)], ['1/p0', '1/p0/0/0']], + ['1/p0/0/t#h0', '实参', ['1/p0']], + ['1/p0/0/t/0#0', [name('z', 64)], ['1/p0']], + ]) + }) + + it('一行译文:译文,拆开的各项,实参表,说明,几行属性', () => { + const line: WrittenBlock = { + ...textBlock('4', 'statement', 10, [word('设'), name('a', 1)]), + items: [{ cells: [[name('k', 3)], [name('v', 5)]] }], + table: { columns: ['参数'], items: [{ cells: [[name('p', 7)]] }] }, + doc: { markdown: '说明' }, + rows: [{ label: '注释', value: { markdown: '备注' } }], + } + expect(texts(placed([line])[0])).toEqual([ + ['4#text', [word('设'), name('a', 1)], []], + ['4/i/0#0', [name('k', 3)], []], + ['4/i/0#1', [name('v', 5)], []], + ['4/t#h0', '参数', []], + ['4/t/0#0', [name('p', 7)], []], + ['4#doc', { markdown: '说明' }, []], + ['4/p0#label', '注释', []], + ['4/p0#value', { markdown: '备注' }, []], + ]) + }) + + it('用键盘跳到一处出现也按它找:一行译文下面几行属性里显示着的名字也算', () => { + const line: WrittenBlock = { ...textBlock('0', 'statement', 0, [word('设')]), rows: [{ label: '值', value: [name('get_db', 30)] }] } + expect(occurrenceIn([line], { start: 30, end: 36, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] }) + }) +}) + describe('跳到定义:滚到哪块、展开哪些', () => { it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { expect(revealIn(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index d186a96..5ffa095 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -1,8 +1,9 @@ import type { Placement } from './placement' -import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types' +import type { BlockView, CardView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types' import { blockPath } from './filter' import { showsOccurrence, sourceOf } from './occurrences' -import { listId, propertyItemId, propertyRowId, tableId, unfoldToShow } from './viewState' +import { subRowsLayout, tableCells } from './subRows' +import { listId, propertyItemId, propertyRowId, tableId, textKey, unfoldToShow } from './viewState' /** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */ export interface RevealPlan { @@ -23,18 +24,18 @@ interface Spot { defining: boolean } -interface Placed { +export interface Placed { block: BlockView /** 要让它看得见得展开的块、属性行(从顶层往下,见 unfoldToShow) */ ancestors: string[] } /** 按页面上的先后(先父块、再子块)列出所有块。 */ -function* placed(blocks: BlockView[], ancestors: string[] = []): Generator { +export function* placedBlocks(blocks: BlockView[], ancestors: string[] = []): Generator { for (const block of blocks) { yield { block, ancestors } for (const child of block.children) - yield* placed([child], [...ancestors, ...unfoldToShow(block.id, child)]) + yield* placedBlocks([child], [...ancestors, ...unfoldToShow(block.id, child)]) } } @@ -156,15 +157,6 @@ function shownItem(items: Iterable, offset: number): Spot | undefine return undefined } -/** 一层层的子行里,格子满足 test(显示着要找的名字)的第一个:要展开的各层。 */ -function showingItem(items: Iterable, test: (cell: PreviewText) => boolean): string[] | undefined { - for (const { item, expand } of items) { - if (item.cells.some(test)) - return expand - } - return undefined -} - /** 在几块里挑目标时,`spot` 是否比眼下选中的 `best` 更贴近这处定义:盖住下标的那段更短的更贴近,一样短时讲这个定义的胜过只是显示着这个名字的。 */ function closer(spot: Spot, best: Spot): boolean { return spot.span !== best.span ? spot.span < best.span : spot.defining && !best.defining @@ -183,7 +175,7 @@ export function revealPlan(blocks: BlockView[], placement: Placement, line: numb let best: { plan: RevealPlan, spot: Spot } | undefined let byLine: Placed | undefined let byLineSpan = Number.POSITIVE_INFINITY - for (const item of placed(blocks)) { + for (const item of placedBlocks(blocks)) { const place = placement.of(item.block.id) if (!place) continue @@ -213,15 +205,82 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap { + if (!('translation' in block)) { + yield* rowTexts(block.id, block.properties) + return + } + yield { key: textKey(block.id, 'text'), text: block.translation, expand: [] } + yield* itemTexts(block.items, listId(block.id), []) + if (block.table) + yield* tableTexts(block.table, tableId(block.id), []) + if (block.doc) + yield { key: textKey(block.id, 'doc'), text: block.doc, expand: [] } + if (block.rows) + yield* rowTexts(block.id, block.rows) +} + +/** 属性表各行的字:子行折叠在行上,给了列名的排成表格,没给的排成列表(见 [subRowsLayout])。 */ +function* rowTexts(blockId: string, rows: PropertyRow[]): Generator { + for (const [index, row] of rows.entries()) { + const id = propertyRowId(blockId, index) + yield { key: textKey(id, 'label'), text: row.label, expand: [] } + yield { key: textKey(id, 'value'), text: row.value, expand: [] } + if (row.note) + yield { key: textKey(id, 'note'), text: row.note, expand: [] } + const layout = subRowsLayout(row) + if (layout === 'table') + yield* tableTexts({ columns: row.columns ?? [], items: row.items ?? [] }, id, [id]) + else if (layout === 'list') + yield* itemTexts(row.items, id, [id]) + } +} + +/** 排成列表的一层子行(`parentId` 下面)的格子:项下面的下一层折叠在项上,项带的实参表总是展开的。`expand` 是让这一层露出来得展开的。 */ +function* itemTexts(items: PropertyItem[] | undefined, parentId: string, expand: string[]): Generator { + for (const [index, item] of (items ?? []).entries()) { + const id = propertyItemId(parentId, index) + for (const [cell, text] of item.cells.entries()) + yield { key: textKey(id, String(cell)), text, expand } + yield* itemTexts(item.items, id, [...expand, id]) + if (item.table) + yield* tableTexts(item.table, tableId(id), expand) + } +} + +/** 一张表(`parentId` 下面)的列名和各行按列名取的格子(见 [tableCells]);行下面拆开的列表、套着的实参表总是展开的。 */ +function* tableTexts(table: PreviewTable, parentId: string, expand: string[]): Generator { + for (const [column, name] of table.columns.entries()) + yield { key: textKey(parentId, `h${column}`), text: name, expand } + for (const [index, item] of table.items.entries()) { + const id = propertyItemId(parentId, index) + for (const [cell, text] of tableCells(item, table.columns).entries()) + yield { key: textKey(id, String(cell)), text, expand } + yield* itemTexts(item.items, id, expand) + if (item.table) + yield* tableTexts(item.table, tableId(id), expand) + } +} + +/** 块里按页面先后第一段满足 `test` 的字(属性名、列名不算):要展开的属性行和子行;不在这块里是 `undefined`。 */ function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): string[] | undefined { - if ('translation' in block) - return test(block.translation) ? [] : showingItem(textItems(block.id, block.items, block.table), test) - for (const [index, row] of block.properties.entries()) { - if (test(row.value)) - return [] - const expand = showingItem(rowItems(row, propertyRowId(block.id, index)), test) - if (expand) + for (const { text, expand } of cardTexts(block)) { + if (typeof text !== 'string' && test(text)) return expand } return undefined @@ -232,7 +291,7 @@ function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): st * skip 的块(被筛掉的、正在编辑的、被正在编辑的祖先盖住的)里的不算;Markdown 说明里的名字不带下标,也不算。页面上哪儿都没有时是 undefined。 */ export function occurrencePlan(blocks: BlockView[], placement: Placement, occurrence: Occurrence, skip: (id: string) => boolean): RevealPlan | undefined { - for (const { block, ancestors } of placed(blocks)) { + for (const { block, ancestors } of placedBlocks(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) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index f916908..0f4083f 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -520,6 +520,8 @@ export interface CodeColors { * rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令) * rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 + * openFind:打开查找栏(⌘F / Ctrl+F),聚焦输入框;页面上选中了一行字时先填进去 + * moveFind:查找栏里跳到下一处(step 1)/ 上一处(step -1)命中(⌘G / ⇧⌘G,Windows 是 F3 / Shift+F3) * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的文件才有);每来一份文档之后补来,基准变了、换了从哪儿打开再来;null 是不比了 * openedFrom:这个标签页是从哪儿打开的,记进页面状态;ready 后来一次,开着时又从侧栏点开、换了从哪儿打开的再来 @@ -580,6 +582,8 @@ export type ExtensionToWebview | ({ type: 'stickyScroll' } & StickyScrollSettings) | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } | { type: 'moveOccurrence', step: 1 | -1 } + | { type: 'openFind' } + | { type: 'moveFind', step: 1 | -1 } | { type: 'displayNames', version: number, names: ShownName[] } | { type: 'filter', excluded: Excluded, onlyChanges: boolean } | ({ type: 'codeColors' } & CodeColors) @@ -622,6 +626,7 @@ export type ExtensionToWebview * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectProfile:kind 那种对话换 profile;不在本地先改,等扩展端发回这种的 profiles 再按它显示 * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发 + * findNavigation:眼下查找栏的快捷键(下一处 / 上一处)归不归这个页面(查找栏开着、页面有焦点),变了才发 * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录; * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference), * 从那块的声明起一层层往外找 @@ -651,6 +656,7 @@ export type WebviewToExtension | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectProfile', kind: ChatKind, id: string } | { type: 'occurrenceNavigation', available: boolean } + | { type: 'findNavigation', available: boolean } | { type: 'openRepoLink', path?: string, symbol?: string, block?: string } | { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] } | { type: 'checkRepoImages', id: number, paths: string[] } diff --git a/vscode/webview-ui/src/codeReader/useFind.ts b/vscode/webview-ui/src/codeReader/useFind.ts new file mode 100644 index 0000000..659a5eb --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useFind.ts @@ -0,0 +1,210 @@ +import type { NameMap } from './displayNames' +import type { FindAnchor, FindMatch, FindResult, FindUnit } from './find' +import type { FindPainter } from './findHighlights' +import type { TextMarks } from './SegmentText' +import type { ReadableStore } from './store' +import type { OccurrencePage } from './useOccurrences' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { findCount, findUnits, PageFind } from './find' +import { markdownText, textOf } from './findHighlights' +import { ShortcutContext } from './occurrences' +import { pageLayout } from './pageLayout' +import { useStore } from './store' +import { showInViewport } from './useReveal' +import { onMessage, postMessage } from './vscode' + +/** 打字停下多久才找:大文件里每敲一个字都整份找一遍、重画一遍高亮会卡 */ +const TYPING_MS = 100 + +const NO_UNITS: readonly FindUnit[] = [] +const NO_NAMES: NameMap = new Map() +const NOTHING: FindResult = { query: '', matches: [] } + +/** 查找栏要画的和它的动作,见 [FindBar](vscode/webview-ui/src/codeReader/FindBar.tsx#FindBar)。 */ +export interface FindView { + open: boolean + query: string + count?: string + canMove: boolean + focus: number + onQuery: (query: string) => void + onMove: (step: 1 | -1) => void + onClose: () => void +} + +/** 一次找:查的字,从哪往后找(⌘F 拿选中的字填进去时是选中的那处)。 */ +interface Search { + query: string + from?: FindAnchor +} + +/** + * 页面上选中的一行字,和查找从哪开始(它在页面内容 `root` 里哪段字的第几个字);没选中、选中了好几行、焦点在输入框里时没有。 + * `line` 是那块在文件里的第一行(见 [FindAnchor])。 + */ +function selectedText(root: HTMLElement | null, page: OccurrencePage | undefined): Search | undefined { + const selection = window.getSelection() + const query = selection?.toString() ?? '' + const field = document.activeElement + if (!selection || selection.rangeCount === 0 || query.trim() === '' || /[\r\n]/.test(query) || field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) + return undefined + const { startContainer, startOffset } = selection.getRangeAt(0) + const holder = (startContainer instanceof Element ? startContainer : startContainer.parentElement)?.closest('[data-find]') + const node = holder && root?.contains(holder) ? textOf(holder).nodes.find(item => item.node === startContainer) : undefined + const block = holder?.closest('.cr-block')?.dataset.id + if (!holder || !node || block === undefined) + return { query } + return { query, from: { key: holder.dataset.find, start: node.start + startOffset, line: page?.placement.of(block)?.line ?? 0 } } +} + +/** 视口顶部那块(钉住的筛选栏下面)的行:查找栏刚写字、还没有当前那处时从它往后找。 */ +function viewportAnchor(): FindAnchor { + return { start: 0, line: pageLayout.anchorAt(window.scrollY + pageLayout.pinnedHeight())?.line ?? 0 } +} + +/** + * 查找栏:扩展端的「查找」命令(⌘F)发来 openFind 时打开、聚焦输入框,页面上选中了一行字时先填进去、从它那处开始找; + * 写了字停下一会儿就在整份文件里找(见 [PageFind]),当前那处展开把它折叠住的块、属性行和子行,滚进视口; + * 「查找下一处 / 上一处」命令(⌘G / ⇧⌘G)发来 moveFind、输入框里 Enter / Shift+Enter、栏上的按钮都在命中之间跳。 + * 高亮由 `painter` 画在页面上,块树不重画;关掉时清掉高亮,查的字留着,下次打开照旧。 + * 查找栏开着、页面有焦点时告诉扩展端 ⌘G 归这个页面(见 [ShortcutContext])。 + * `page` 同单击高亮跳到一处出现用的页面状态,`marks` 是页面文字的标记(中文名在里面,找的时候才订阅,中文名到了不连累整页重画)。 + */ +export function useFind(page: OccurrencePage | undefined, marks: ReadableStore, painter: FindPainter): FindView { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [search, setSearch] = useState({ query: '' }) + /** 每次 ⌘F 加一:聚焦输入框、选中里面的字 */ + const [focus, setFocus] = useState(0) + const [result, setResult] = useState(NOTHING) + const [find] = useState(() => new PageFind(setResult)) + const [shortcuts] = useState(() => new ShortcutContext( + { open: false, focused: document.hasFocus() }, + facts => facts.open && facts.focused, + available => postMessage({ type: 'findNavigation', available }), + )) + /** 跳过几次:每跳一次,画完后把当前那处滚进视口 */ + const [reveals, setReveals] = useState(0) + const latest = useRef({ page, open, query, search }) + + useLayoutEffect(() => { + latest.current = { page, open, query, search } + }) + + const reachable = useCallback((unit: FindUnit) => latest.current.page?.skip(unit.block) === false, []) + + /** 跳到 match:先展开把它折叠住的,画完后滚过去 */ + const reveal = useCallback((match: FindMatch) => { + const current = latest.current.page + const folded = current ? find.unitOf(match).expand.filter(id => current.collapsed.has(id)) : [] + if (current && folded.length > 0) + current.expand(folded) + setReveals(count => count + 1) + }, [find]) + + // 开着、写了字才把整份文件的字备好;关着时不跟着新文档重算,原来那处留着,下次打开从它附近接着 + const active = open && search.query !== '' + const names = useStore(marks, current => active ? current.names : NO_NAMES) + const blocks = page?.blocks + const placement = page?.placement + const units = useMemo(() => active && blocks && placement ? findUnits(blocks, placement, names, markdownText) : NO_UNITS, [active, blocks, placement, names]) + + // 先换上新的字(新一版文档、中文名到了、又打开了)再找:当前那处停在原来那处附近,不滚 + useLayoutEffect(() => { + if (active) + find.setUnits(units, reachable) + }, [find, active, units, reachable]) + + useLayoutEffect(() => { + const from = search.from ?? (search.query !== '' && find.state.current === undefined ? viewportAnchor() : undefined) + const match = find.search(search.query, reachable, from) + if (match) + reveal(match) + }, [find, search, reachable, reveal]) + + useEffect(() => { + const timer = window.setTimeout(() => setSearch(previous => previous.query === query ? previous : { query }), TYPING_MS) + return () => window.clearTimeout(timer) + }, [query]) + + // 结果变了、开关了:照着画;之后页面变了(展开、换了一版文档)由 painter 自己下一帧重画 + useLayoutEffect(() => { + painter.show(open && result.query !== '' ? find.paint() : undefined) + }, [painter, find, open, result]) + + useLayoutEffect(() => { + if (reveals === 0) + return + // 要展开的已经展开:按展开后的页面重画一遍,再滚到当前那处;它画不出来就滚到它那段字、那块 + const painted = painter.show(latest.current.open ? find.paint() : undefined) + const { current, matches } = find.state + const block = current === undefined ? undefined : find.unitOf(matches[current]).block + const target = painted.current ?? painted.text ?? (block === undefined ? null : document.querySelector(`.cr-block[data-id="${block}"]`)) + if (target) + showInViewport(target) + painter.place() + }, [reveals, painter, find]) + + const move = useCallback((step: 1 | -1) => { + const { open, query, search } = latest.current + if (!open) + return + // 刚写的字还没找:先找,跳到它的第一处 + if (query !== search.query) { + setSearch({ query }) + return + } + const match = find.move(step, reachable) + if (match) + reveal(match) + }, [find, reachable, reveal]) + + const close = useCallback(() => { + const focused = document.activeElement + if (focused instanceof HTMLElement && focused.closest('.cr-find-bar')) + focused.blur() + setOpen(false) + }, []) + + useEffect(() => onMessage((msg) => { + if (msg.type === 'openFind') { + const selected = selectedText(painter.root, latest.current.page) + if (selected) { + setQuery(selected.query) + setSearch(selected) + } + setOpen(true) + setFocus(count => count + 1) + } + else if (msg.type === 'moveFind') { + move(msg.step) + } + }), [painter, move]) + + useEffect(() => shortcuts.update({ open }), [shortcuts, open]) + + useEffect(() => { + const onFocus = (): void => shortcuts.update({ focused: true }) + const onBlur = (): void => shortcuts.update({ focused: false }) + window.addEventListener('focus', onFocus) + window.addEventListener('blur', onBlur) + // 焦点落进页面里(⌘F 聚焦输入框)时页面一定有焦点 + document.addEventListener('focusin', onFocus) + return () => { + window.removeEventListener('focus', onFocus) + window.removeEventListener('blur', onBlur) + document.removeEventListener('focusin', onFocus) + } + }, [shortcuts]) + + return { + open, + query, + count: open ? findCount(result) : undefined, + canMove: result.matches.length > 0, + focus, + onQuery: setQuery, + onMove: move, + onClose: close, + } +} diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index 26752b0..6ab0588 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -18,7 +18,7 @@ const CONTROLS = `a, button, input, select, textarea, [role="listbox"], [role="d /** 不收文字的输入框:焦点在它们上面时方向键不归它们移光标 */ const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset']) -/** 用键盘跳到一处出现时要用的页面状态。 */ +/** 用键盘跳到一处出现、查找栏跳到一处命中时要用的页面状态。 */ export interface OccurrencePage { blocks: BlockView[] /** 各块在文件里的位置:点中的名字、各处出现按它和文件里的下标对上 */ diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 96097b5..d465fc0 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -27,10 +27,15 @@ export function flash(el: HTMLElement): void { }, end)) } -/** 把 el 滚进钉住的筛选栏和卡片下面的视口,量之前先把它那一带排版好(pageLayout.settle)。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */ -export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void { - pageLayout.settle(el) - const rect = el.getBoundingClientRect() +/** + * 把 target(一个元素,或查找栏的一处命中这样的一段字)滚进钉住的筛选栏和卡片下面的视口,量之前先把它那一带排版好(pageLayout.settle)。 + * 已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 + */ +export function showInViewport(target: HTMLElement | Range, beforeScroll?: (position: ScrollPosition) => void): void { + const el = target instanceof Range ? target.startContainer.parentElement : target + if (el) + pageLayout.settle(el) + const rect = target.getBoundingClientRect() const pinned = pageLayout.pinnedHeight() + stickyHeight() const top = scrollTopToShow( { top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 96c2f3b..f1e20d6 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -165,6 +165,15 @@ export function listId(blockId: string): string { return `${blockId}/i` } +/** + * 卡片上一段字的记号,画在显示它的元素上(data-find):`<所在块、属性行、子行或表的 id>#<哪一段>`, + * 例如 '6.1.0#text' 是这块的一行译文,'6.1.0/p3#label' 是第 3 行的属性名,'6.1.0/p3/1#0' 是它第 1 个子行的第 0 格,'6.1.0/p3#h0' 是那张表的第 0 个列名。 + * 查找栏按它把算好的命中落到页面上的字上。 + */ +export function textKey(ownerId: string, part: string): string { + return `${ownerId}#${part}` +} + /** * 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画); * 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己。