✨ 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

+134 -12
View File
@@ -1,4 +1,4 @@
import type { Occurrence, Segment, WebviewToExtension } from './types'
import type { Inline, KeyCombo, Occurrence, Segment, WebviewToExtension } from './types'
/** 高亮画成哪种底色,和 VS Code 编辑器一样:写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色。 */
export type OccurrenceStyle = 'strong' | 'normal'
@@ -12,13 +12,9 @@ export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle {
* 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。
*/
export function segmentOccurrence(segment: Segment, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined {
const { offset } = segment
if (offset === undefined)
return undefined
const end = offset + segment.text.length
let style: OccurrenceStyle | undefined
for (const occurrence of occurrences) {
if (occurrence.start < end && offset < occurrence.end) {
if (overlaps(segment, occurrence)) {
style = occurrenceStyle(occurrence.kind)
if (style === 'strong')
return style
@@ -27,6 +23,17 @@ export function segmentOccurrence(segment: Segment, occurrences: readonly Occurr
return style
}
/** 段照抄自源码、在源码里占的 [offset, offset + 字数) 和这处出现重叠;没有下标的段不算。 */
export function overlaps(segment: Segment, occurrence: Occurrence): boolean {
const { offset } = segment
return offset !== undefined && occurrence.start < offset + segment.text.length && offset < occurrence.end
}
/** 文字里(连框里)有段和这处出现重叠。 */
export function showsOccurrence(pieces: readonly Inline[], occurrence: Occurrence): boolean {
return pieces.some(piece => 'role' in piece ? overlaps(piece, occurrence) : showsOccurrence(piece.segments, occurrence))
}
/** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */
export interface Click {
/** 按着 ⌘ / Ctrl / Alt / Shift:不是单纯的单击(⌘ / Ctrl + 点击归跳到定义) */
@@ -62,20 +69,68 @@ export function clickAction(click: Click): ClickAction {
/** 眼下高亮着的名字:点中时它的文字(筛选栏上显示)和它在文档里的各处出现。 */
export interface Highlight {
name: string
/** 照源码先后排 */
occurrences: readonly Occurrence[]
/** 当前那一处(点中的、用键盘跳到的)在 occurrences 里的序号;点中的名字不在回答里时没有 */
current?: number
}
/** 段是不是当前那一处出现(点中的、用键盘跳到的)。 */
export function isCurrent(segment: Segment, highlight: Highlight): boolean {
const current = highlight.current === undefined ? undefined : highlight.occurrences[highlight.current]
return current !== undefined && overlaps(segment, current)
}
/** 筛选栏上名字后面写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */
export function occurrenceCount(highlight: Highlight): string {
const total = highlight.occurrences.length
return highlight.current === undefined ? `${total} 处` : `${highlight.current + 1}/${total}`
}
/**
* 往后(step 1)/ 往前(step -1)数,下一处能跳到的出现的序号,到头了从另一头接着数;一处都跳不到时没有。
* 从当前那一处(current)数起;没有当前那一处时从源码下标 from(点中的名字)数起。
*/
export function stepIndex(
occurrences: readonly Occurrence[],
current: number | undefined,
from: number,
step: 1 | -1,
reachable: (occurrence: Occurrence) => boolean,
): number | undefined {
const count = occurrences.length
// 没有当前那一处时,起点是 from 前面最后一处(往后数)/ 后面第一处(往前数),数的时候跳过它本身所在的位置
const start = current ?? (step === 1
? occurrences.filter(occurrence => occurrence.start <= from).length - 1
: count - occurrences.filter(occurrence => occurrence.start >= from).length)
for (let k = 1; k <= count; k++) {
const index = (((start + step * k) % count) + count) % count
if (reachable(occurrences[index]))
return index
}
return undefined
}
/** 按下的键是不是这个组合:键名(字母不分大小写)和四个修饰键都对上。 */
export function matchesKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>, combo: KeyCombo): boolean {
return event.key.toLowerCase() === combo.key.toLowerCase()
&& event.ctrlKey === combo.ctrl && event.shiftKey === combo.shift && event.altKey === combo.alt && event.metaKey === combo.meta
}
/**
* 单击名字高亮它在文档里的各处出现,和编辑器里一样每回点都现问扩展端,回答不留到下次用;
* 只认最近一次询问的回答。回答到之前旧高亮留着(点同一个名字的另一处不闪),回答是空的就不高亮。
* 高亮着时能用键盘在各处出现之间跳(move),跳到的那处是当前那一处。
*/
export class OccurrenceHighlight {
private version: number | undefined
private translatorId: string | undefined
/** 最近一次询问的 id;别的回答(点了别处、清掉之后才到的)都丢掉 */
private request = 0
/** 最近一次询问点中的名字;答过或清掉后没有 */
private asked: string | undefined
/** 最近一次询问点中的那段名字;答过或清掉后没有 */
private asked: Segment | undefined
/** 高亮着的名字没有当前那一处时,从这个源码下标(点中的名字)数起跳到哪一处 */
private from = 0
private current: Highlight | undefined
constructor(
@@ -103,12 +158,29 @@ export class OccurrenceHighlight {
this.find(action.offset, action.name)
}
/** 回答照源码先后排好;点中的那处就是当前那一处。 */
answer(id: number, occurrences: Occurrence[]): void {
if (id !== this.request || this.asked === undefined)
const asked = this.asked
if (id !== this.request || asked === undefined)
return
const name = this.asked
this.asked = undefined
this.show(occurrences.length > 0 ? { name, occurrences } : undefined)
const sorted = [...occurrences].sort((a, b) => a.start - b.start || a.end - b.end)
const current = sorted.findIndex(occurrence => overlaps(asked, occurrence))
this.from = asked.offset ?? 0
this.show(sorted.length > 0 ? { name: asked.text, occurrences: sorted, ...(current >= 0 && { current }) } : undefined)
}
/**
* 当前那一处换成往后(step 1)/ 往前(step -1)的下一处能跳到的出现(见 stepIndex),返回它;
* 没有高亮、一处都跳不到时不动,返回 undefined。
*/
move(step: 1 | -1, reachable: (occurrence: Occurrence) => boolean): Occurrence | undefined {
const highlight = this.current
const index = highlight && stepIndex(highlight.occurrences, highlight.current, this.from, step, reachable)
if (!highlight || index === undefined)
return undefined
this.show({ ...highlight, current: index })
return highlight.occurrences[index]
}
clear(): void {
@@ -120,7 +192,7 @@ export class OccurrenceHighlight {
private find(offset: number, name: string): void {
if (this.version === undefined)
return
this.asked = name
this.asked = { text: name, role: 'plain', offset }
this.post({ type: 'findOccurrences', id: ++this.request, version: this.version, offset })
}
@@ -131,3 +203,53 @@ export class OccurrenceHighlight {
this.changed(highlight)
}
}
/** 能不能用键盘跳要看的几件事。 */
export interface NavigationFacts {
/** 有名字高亮着 */
highlighted: boolean
/** 页面有焦点:按的键才会到这个页面 */
focused: boolean
/** 焦点在输入框里:⌘↑ / ⌘↓ 这些键归输入框移光标 */
typing: boolean
}
/**
* 眼下能不能用键盘在高亮着的名字的各处出现之间跳:有高亮、页面有焦点、焦点不在输入框里。变了才告诉扩展端,
* 扩展端跟着设快捷键 when 子句用的上下文键;页面也按它决定要不要拦下这些键在浏览器里的默认动作(滚到顶、滚到底)。
*/
export class OccurrenceNavigation {
private facts: NavigationFacts
private reported: boolean | undefined
constructor(private readonly post: (msg: WebviewToExtension) => void, initial: Omit<NavigationFacts, 'highlighted'>) {
this.facts = { highlighted: false, ...initial }
}
get available(): boolean {
const { highlighted, focused, typing } = this.facts
return highlighted && focused && !typing
}
/** 改了哪几件事;第一次调用时不管变没变都告诉扩展端(页面重新加载后扩展端记的可能是旧页面的) */
update(change: Partial<NavigationFacts>): void {
this.facts = { ...this.facts, ...change }
this.report()
}
/**
* 这个键要不要拦下浏览器的默认动作:能跳、又是那两条命令绑的键。VS Code 把页面上按的每个键都转给自己的快捷键系统,
* 但不回头告诉页面有没有命令接了它,浏览器的默认动作又只能在按下的当下拦,所以只能照命令绑的键(keys)当场判断。
*/
blocks(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>, keys: readonly KeyCombo[]): boolean {
return this.available && keys.some(combo => matchesKey(event, combo))
}
private report(): void {
const { available } = this
if (available === this.reported)
return
this.reported = available
this.post({ type: 'occurrenceNavigation', available })
}
}