✨ feat(vscode): 代码阅读预览加 ⌘F 查找栏:整份文件查(折叠着的块里的也算,跳过去时展开),按页面上显示的字不分大小写匹配、中文名也认原名,Enter / ⌘G 跳、Esc 关,高亮直接画在页面上不重画卡片

This commit is contained in:
cruldra committed 2026-10-14 05:40:59 +11:00
1 parent cc3fcda051
commit bd474986bd
27 files changed
+1833 -114

No files matched your search

@@ -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'`)
})
})
@@ -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'
+6
View File
@@ -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[] }
@@ -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<typeof tab>): 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]])
})
})
@@ -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<string, OpenRequest>()
/** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */
readonly navigation = new NavigationContext<Preview>(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value))
/** 哪个预览眼下查找栏开着、有焦点,跟着它设查找下一处 / 上一处快捷键 when 子句用的上下文键 */
readonly finding = new NavigationContext<Preview>(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)
}
}
+4
View File
@@ -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