✨ feat(vscode): 代码阅读的名字高亮更显眼(当前那处描框、别处画细线),⌘↓ / ⌘↑ 在各处之间跳,跳到折叠处自动展开,筛选栏写第几处

This commit is contained in:
cruldra committed 2026-09-26 23:47:43 +08:00
1 parent c5c5d2b517
commit 49f253b80f
18 files changed
+731 -53

No files matched your search

+20
View File
@@ -32,6 +32,19 @@ export interface Occurrence {
kind: 'text' | 'read' | 'write'
}
/**
* 一个按键组合,按 KeyboardEvent 的写法:key 是 KeyboardEvent.key('ArrowDown'),四个修饰键都要对上。
* webview 收到的每个按键 VS Code 都会转给自己的快捷键系统,但不告诉 webview 有没有命令接了它;
* 浏览器对这个键的默认动作(⌘↓ 滚到底)又只能在按下的当下拦,所以 webview 要事先知道命令绑的是哪些键。
*/
export interface KeyCombo {
key: string
ctrl: boolean
shift: boolean
alt: boolean
meta: boolean
}
export interface DocumentMessage {
type: 'document'
/** TextDocument.version:webview 靠它判断是不是新一份文档 */
@@ -85,12 +98,16 @@ export interface QaMessage {
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
* occurrenceKeys:「跳到下一处 / 上一处高亮」这两条命令在 package.json 里默认绑的键;ready 后发一次,见 KeyCombo
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
*/
export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'occurrenceKeys', keys: KeyCombo[] }
| { type: 'moveOccurrence', step: 1 | -1 }
| { type: 'filter', excluded: Excluded }
| QaMessage
| ({ type: 'askProfiles' } & ProfileChoice)
@@ -110,6 +127,8 @@ export type ExtensionToWebview
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发;
* 扩展端按它设 when 子句用的上下文键
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -124,3 +143,4 @@ export type WebviewToExtension
| { type: 'stopAsk', askId: string }
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string }
| { type: 'occurrenceNavigation', available: boolean }
@@ -0,0 +1,92 @@
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { NavigationContext, navigationKeys, NEXT_OCCURRENCE, OCCURRENCES_CONTEXT, parseKey, PREVIOUS_OCCURRENCE } from './occurrenceNavigation'
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
describe('跳到下一处 / 上一处高亮:package.json 里的命令和快捷键', () => {
it('两条命令叫「代码阅读:跳到下一处高亮 / 跳到上一处高亮」', () => {
expect(commands.filter(c => c.command === NEXT_OCCURRENCE || c.command === PREVIOUS_OCCURRENCE).map(c => `${c.category}:${c.title}`))
.toEqual(['代码阅读:跳到下一处高亮', '代码阅读:跳到上一处高亮'])
})
it('默认键:mac 上 ⌘↓ / ⌘↑,别的系统 Ctrl+↓ / Ctrl+↑;只在代码阅读预览是活动编辑器、里面有高亮时生效', () => {
const when = `activeCustomEditorId == ${PREVIEW} && ${OCCURRENCES_CONTEXT}`
expect(keybindings.filter(k => k.command === NEXT_OCCURRENCE || k.command === PREVIOUS_OCCURRENCE)).toEqual([
{ command: NEXT_OCCURRENCE, key: 'ctrl+down', mac: 'cmd+down', when },
{ command: PREVIOUS_OCCURRENCE, key: 'ctrl+up', mac: 'cmd+up', when },
])
})
it('命令面板里只在代码阅读预览是活动编辑器时出现', () => {
expect(menus.commandPalette.filter(item => item.command === NEXT_OCCURRENCE || item.command === PREVIOUS_OCCURRENCE)).toEqual([
{ command: NEXT_OCCURRENCE, when: `activeCustomEditorId == ${PREVIEW}` },
{ command: PREVIOUS_OCCURRENCE, when: `activeCustomEditorId == ${PREVIEW}` },
])
})
it('页面要拦的键取自这两条命令默认绑的键,各平台的都算、重复的去掉', () => {
expect(navigationKeys(manifest)).toEqual([
{ key: 'ArrowDown', ctrl: true, shift: false, alt: false, meta: false },
{ key: 'ArrowDown', ctrl: false, shift: false, alt: false, meta: true },
{ key: 'ArrowUp', ctrl: true, shift: false, alt: false, meta: false },
{ key: 'ArrowUp', ctrl: false, shift: false, alt: false, meta: true },
])
expect(navigationKeys({ contributes: { keybindings: [
{ command: NEXT_OCCURRENCE, key: 'alt+j', linux: 'alt+j', win: 'ctrl+k ctrl+j' },
{ command: 'other', key: 'cmd+down' },
] } })).toEqual([{ key: 'j', ctrl: false, shift: false, alt: true, meta: false }])
expect(navigationKeys(undefined)).toEqual([])
})
})
describe('快捷键写法 → 按键组合', () => {
it('修饰键、方向键、字母;cmd / meta / win 都是 meta', () => {
expect(parseKey('cmd+down')).toEqual({ key: 'ArrowDown', ctrl: false, shift: false, alt: false, meta: true })
expect(parseKey('Ctrl+Shift+PageUp')).toEqual({ key: 'PageUp', ctrl: true, shift: true, alt: false, meta: false })
expect(parseKey('win+k')).toEqual({ key: 'k', ctrl: false, shift: false, alt: false, meta: true })
})
it('分两下按的、认不得的键名、认不得的修饰键没有', () => {
expect(parseKey('ctrl+k ctrl+x')).toBeUndefined()
expect(parseKey('ctrl+f5')).toBeUndefined()
expect(parseKey('hyper+down')).toBeUndefined()
expect(parseKey('ctrl+')).toBeUndefined()
})
})
describe('上下文键跟着各预览报来的状态走', () => {
it('有一个预览能跳就是真,变了才设;预览关掉(报不能跳)时跟着变', () => {
const set: boolean[] = []
const context = new NavigationContext<string>(value => set.push(value))
context.update('a', false)
context.update('a', true)
context.update('a', true)
// 焦点从 a 挪到 b:两边的消息先后不定
context.update('b', true)
context.update('a', false)
context.update('b', false)
expect(set).toEqual([true, false])
})
it('命令发给报了能跳的预览;都没报(从命令面板运行)时发给活动的预览', () => {
const context = new NavigationContext<string>(() => {})
const previews = ['a', 'b', 'c']
expect(context.target(previews, id => id === 'c')).toBe('c')
context.update('b', true)
expect(context.target(previews, id => id === 'c')).toBe('b')
expect(context.target([], () => true)).toBeUndefined()
})
})
@@ -0,0 +1,85 @@
import type { KeyCombo } from './messages'
/** 在高亮着的名字的各处出现之间跳:两条命令、快捷键的 when 子句用的上下文键。 */
export const NEXT_OCCURRENCE = 'superpowers.codeReader.nextOccurrence'
export const PREVIOUS_OCCURRENCE = 'superpowers.codeReader.previousOccurrence'
/** 活动的预览里能用键盘跳(有高亮、页面有焦点、焦点不在输入框里)。 */
export const OCCURRENCES_CONTEXT = 'superpowers.codeReader.hasOccurrences'
/** VS Code 快捷键写法里的键名 → KeyboardEvent.key;单个字母、数字照写。 */
const KEY_NAMES: Record<string, string> = {
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
home: 'Home',
end: 'End',
pageup: 'PageUp',
pagedown: 'PageDown',
}
/** `cmd+down` 这种一下按完的组合 → KeyCombo;`ctrl+k ctrl+x` 这种分两下按的、认不得的键名没有。 */
export function parseKey(text: string): KeyCombo | undefined {
const parts = text.trim().toLowerCase().split('+')
if (parts.some(part => part === '' || /\s/.test(part)))
return undefined
const name = parts.pop() ?? ''
const key = KEY_NAMES[name] ?? (/^[a-z0-9]$/.test(name) ? name : undefined)
const modifiers = new Set(parts)
if (!key || [...modifiers].some(modifier => !['ctrl', 'shift', 'alt', 'cmd', 'meta', 'win'].includes(modifier)))
return undefined
return {
key,
ctrl: modifiers.has('ctrl'),
shift: modifiers.has('shift'),
alt: modifiers.has('alt'),
meta: modifiers.has('cmd') || modifiers.has('meta') || modifiers.has('win'),
}
}
interface Keybinding { command?: unknown, key?: unknown, mac?: unknown, linux?: unknown, win?: unknown }
/**
* package.json 给这两条命令默认绑的键,各平台的都算(扩展可能跑在远程机器上,看不出界面在哪个系统)。
* 用户在「键盘快捷方式」里改绑的读不到:VS Code 没有给扩展查快捷键的接口。
*/
export function navigationKeys(manifest: unknown): KeyCombo[] {
const bindings = (manifest as { contributes?: { keybindings?: Keybinding[] } } | undefined)?.contributes?.keybindings ?? []
const combos = bindings
.filter(binding => binding.command === NEXT_OCCURRENCE || binding.command === PREVIOUS_OCCURRENCE)
.flatMap(binding => [binding.key, binding.mac, binding.linux, binding.win])
.filter((text): text is string => typeof text === 'string')
.map(parseKey)
.filter((combo): combo is KeyCombo => combo !== undefined)
return combos.filter((combo, i) => combos.findIndex(other => JSON.stringify(other) === JSON.stringify(combo)) === i)
}
/**
* 上下文键跟着各预览报来的状态走:有一个预览能跳就是真(同一时刻只有一个页面有焦点),变了才设。
* 预览关掉、页面被销毁(标签页切走)时当它不能跳。
*/
export class NavigationContext<Owner> {
private readonly available = new Set<Owner>()
private value = false
constructor(private readonly set: (value: boolean) => void) {}
update(owner: Owner, available: boolean): void {
if (available)
this.available.add(owner)
else
this.available.delete(owner)
const value = this.available.size > 0
if (value === this.value)
return
this.value = value
this.set(value)
}
/** 命令发给哪个预览:报了能跳的那个;从命令面板运行时页面没焦点,就发给活动的那个。 */
target(owners: Iterable<Owner>, active: (owner: Owner) => boolean): Owner | undefined {
const list = [...owners]
return list.find(owner => this.available.has(owner)) ?? list.find(active)
}
}
+27 -3
View File
@@ -7,7 +7,7 @@ import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { BlockFilter } from './blockFilter'
import type { Definition, FoundLocation } from './definitions'
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
import type { ExtensionToWebview, KeyCombo, Reveal, WebviewToExtension } from './messages'
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
@@ -18,6 +18,7 @@ import { definitionAction } from './definitionAction'
import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
@@ -33,6 +34,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
private readonly previews = new Set<Preview>()
/** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */
private readonly reveals = new Map<string, Reveal>()
/** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */
readonly navigation = new NavigationContext<Preview>(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value))
constructor(
private readonly extensionUri: Uri,
@@ -43,6 +46,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
readonly resolveReference: (reference: RuleReference) => Promise<RuleLocation[]>,
/** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */
readonly qa: CodeQa,
/** 「跳到下一处 / 上一处高亮」默认绑的键:页面在这些键上拦下浏览器自己的滚动 */
readonly occurrenceKeys: readonly KeyCombo[],
) {}
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
@@ -56,10 +61,16 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
this.previews.add(preview)
panel.onDidDispose(() => {
this.previews.delete(preview)
this.navigation.update(preview, false)
preview.dispose()
})
}
/** 「跳到下一处 / 上一处高亮」:发给能跳的那个预览,从命令面板运行时发给活动的预览。 */
moveOccurrence(step: 1 | -1): void {
this.navigation.target(this.previews, preview => preview.active)?.moveOccurrence(step)
}
/** 让已打开的预览按当前注册表重发;给了 languageId 就只刷这种语言的。 */
refresh(languageId?: string): void {
for (const preview of this.previews) {
@@ -130,8 +141,11 @@ class Preview {
this.disposables = [
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
panel.onDidChangeViewState(() => {
if (!panel.visible)
this.ready = false
if (panel.visible)
return
// 页面被销毁了,高亮跟着没了
this.ready = false
owner.navigation.update(this, false)
}),
workspace.onDidChangeTextDocument((e) => {
if (e.document === document && e.contentChanges.length > 0)
@@ -202,6 +216,12 @@ class Preview {
this.post(reveal === undefined ? message : { ...message, reveal })
}
/** 跳到高亮着的名字的下一处 / 上一处出现;页面不在就算了(页面重新加载时高亮已经没了)。 */
moveOccurrence(step: 1 | -1): void {
if (this.ready)
this.post({ type: 'moveOccurrence', step })
}
/** 滚到 reveal:页面在就直接发,正在重新加载就等它发 ready 后随文档带过去。 */
reveal(reveal: Reveal): void {
if (this.ready)
@@ -262,6 +282,7 @@ class Preview {
this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', excluded: this.filter.excluded })
this.post({ type: 'occurrenceKeys', keys: [...this.owner.occurrenceKeys] })
this.postQa()
void this.postProfiles()
await this.push()
@@ -299,6 +320,9 @@ class Preview {
return
case 'findOccurrences':
await this.findOccurrences(msg.id, msg.version, msg.offset)
return
case 'occurrenceNavigation':
this.owner.navigation.update(this, msg.available)
}
}
+4 -1
View File
@@ -3,6 +3,7 @@ import type { CodeReaderApi, RuleLocation, RuleReference } from './contract'
import { commands, window, workspace } from 'vscode'
import { logger } from '../logging/logger'
import { BlockFilter } from './editor/blockFilter'
import { navigationKeys, NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
import { showPreview, showSource } from './editor/switchEditor'
import { registerCopyCommands } from './explorer/copyCommands'
@@ -65,7 +66,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
}, report))
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => resolveReference(reference, registry.libraryRules(), indexes, report)
const qa = new CodeQa(context.globalState)
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa)
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, navigationKeys(context.extension.packageJSON))
const notes = new FileNotes()
const files = new FileTreeProvider(OPEN_COMMAND, notes)
@@ -87,6 +88,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => showSource(previews, args)),
commands.registerCommand(NEXT_OCCURRENCE, () => previews.moveOccurrence(1)),
commands.registerCommand(PREVIOUS_OCCURRENCE, () => previews.moveOccurrence(-1)),
)
const api: CodeReaderApi = registry