♻️ refactor(vscode): 代码阅读不再在页面里拦 ⌘↓ / ⌘↑,快捷键只在「键盘快捷方式」里绑;跳到下一处时把浏览器自己的滚动拉回来,页面不再先滚到底

This commit is contained in:
cruldra committed 2026-09-27 00:33:18 +08:00
1 parent 0cad5722bb
commit 1b1299dc1f
10 files changed
+157 -165

No files matched your search

-15
View File
@@ -32,19 +32,6 @@ 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 靠它判断是不是新一份文档 */
@@ -98,7 +85,6 @@ 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
@@ -106,7 +92,6 @@ export type ExtensionToWebview
| ({ 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
@@ -1,7 +1,7 @@
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'
import { NavigationContext, NEXT_OCCURRENCE, OCCURRENCES_CONTEXT, PREVIOUS_OCCURRENCE } from './occurrenceNavigation'
interface Manifest {
contributes: {
@@ -37,33 +37,11 @@ describe('跳到下一处 / 上一处高亮:package.json 里的命令和快捷
])
})
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()
it('键只在 package.json 里绑,扩展、页面的代码里都不写死键:用户改绑后原来的键照常', () => {
const sources = ['occurrenceNavigation.ts', 'previewEditor.ts', 'messages.ts', '../index.ts'].map(file => readFileSync(path.resolve(__dirname, file), 'utf8'))
const webview = ['occurrences.ts', 'useOccurrences.ts', 'types.ts'].map(file => readFileSync(path.resolve(__dirname, '../../../webview-ui/src/codeReader', file), 'utf8'))
for (const text of [...sources, ...webview])
expect(text).not.toMatch(/ArrowDown|ArrowUp|cmd\+|ctrl\+|KeyCombo|keybindings|preventDefault/)
})
})
@@ -1,5 +1,3 @@
import type { KeyCombo } from './messages'
/** 在高亮着的名字的各处出现之间跳:两条命令、快捷键的 when 子句用的上下文键。 */
export const NEXT_OCCURRENCE = 'superpowers.codeReader.nextOccurrence'
@@ -7,54 +5,6 @@ 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)
}
/**
* 上下文键跟着各预览报来的状态走:有一个预览能跳就是真(同一时刻只有一个页面有焦点),变了才设。
* 预览关掉、页面被销毁(标签页切走)时当它不能跳。
@@ -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, KeyCombo, Reveal, WebviewToExtension } from './messages'
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
@@ -46,8 +46,6 @@ 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 {
@@ -282,7 +280,6 @@ 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()
+2 -2
View File
@@ -3,7 +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 { 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'
@@ -66,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, navigationKeys(context.extension.packageJSON))
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa)
const notes = new FileNotes()
const files = new FileTreeProvider(OPEN_COMMAND, notes)