✨ feat(vscode): 代码阅读的名字高亮更显眼(当前那处描框、别处画细线),⌘↓ / ⌘↑ 在各处之间跳,跳到折叠处自动展开,筛选栏写第几处
This commit is contained in:
1 parent
c5c5d2b517
commit
49f253b80f
18 files changed
+731
-53
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user