✨ feat(vscode): 代码阅读预览顶部加块筛选栏

语言插件按和语言无关的 id 报块属性(通用的由宿主从 facts 推),翻译插件起名,webview 只按 id 筛;
筛选整个工作区共用、记在 workspaceState;JavaScript 不再显示「抽象:否」「仅类型:否」
This commit is contained in:
cruldra committed 2026-09-25 21:31:19 +08:00
1 parent 4eaf2ae11d
commit 1c7e31c51a
29 files changed
+1364 -319

No files matched your search

@@ -0,0 +1,44 @@
import type { Memento } from 'vscode'
import type { Excluded } from './blockFilter'
import { describe, expect, it } from 'vitest'
import { BlockFilter, FILTER_KEY, normalizeExcluded } from './blockFilter'
function memento(initial: Record<string, unknown> = {}): Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> } {
const store = { ...initial }
return {
store,
get: <T>(key: string): T | undefined => store[key] as T | undefined,
update: async (key: string, value: unknown) => {
store[key] = value
},
} as Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> }
}
describe('块筛选:整个工作区共用一份', () => {
it('记的是去掉的值,写进 workspaceState,重新打开窗口(新的实例)读得回来', async () => {
const state = memento()
await new BlockFilter(state).set({ access: ['private'], kind: ['comment', 'import'] })
expect(state.store[FILTER_KEY]).toEqual({ access: ['private'], kind: ['comment', 'import'] })
expect(new BlockFilter(state).excluded).toEqual({ access: ['private'], kind: ['comment', 'import'] })
})
it('一个预览改了,所有订阅的预览都收到新的筛选;取消订阅后不再收到', async () => {
const filter = new BlockFilter(memento())
const first: Excluded[] = []
const second: Excluded[] = []
filter.subscribe(excluded => first.push(excluded))
const off = filter.subscribe(excluded => second.push(excluded))
await filter.set({ async: ['true'] })
off.dispose()
await filter.set({})
expect(first).toEqual([{ async: ['true'] }, {}])
expect(second).toEqual([{ async: ['true'] }])
})
it('读回来的、webview 发来的逐项核对:形状不对的当没有,值去重排序,空的属性去掉', () => {
expect(normalizeExcluded(undefined)).toEqual({})
expect(normalizeExcluded(['access'])).toEqual({})
expect(normalizeExcluded({ kind: ['if', 'comment', 'if', 3], access: 'private', async: [] })).toEqual({ kind: ['comment', 'if'] })
expect(new BlockFilter(memento({ [FILTER_KEY]: 'broken' })).excluded).toEqual({})
})
})
@@ -0,0 +1,53 @@
import type { Disposable, Memento } from 'vscode'
/** workspaceState 里记的块筛选:Excluded。 */
export const FILTER_KEY = 'superpowers.codeReader.filter'
/**
* 块筛选:属性 id → 不勾(藏起来)的值 id。记的是去掉的值而不是勾着的值:
* 一份文件里第一次出现的值默认显示,换到没出现过这些值的文件也不用改。
*/
export type Excluded = Record<string, string[]>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 读回来的、webview 发来的筛选逐项核对:只留字符串值,去重排序;一个值都不剩的属性去掉。 */
export function normalizeExcluded(value: unknown): Excluded {
if (!isRecord(value))
return {}
const entries = Object.entries(value).flatMap(([attribute, values]): [string, string[]][] => {
const kept = Array.isArray(values) ? [...new Set(values.filter((item): item is string => typeof item === 'string'))].sort() : []
return kept.length > 0 ? [[attribute, kept]] : []
})
return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)))
}
/**
* 整个工作区共用的一份块筛选:记在 workspaceState 里,重新加载窗口后还在;
* 一个预览改了,所有订阅的预览都收到(包括改的那个,webview 只按扩展端发来的显示)。
* 不依赖 vscode 运行时,便于单元测试。
*/
export class BlockFilter {
private readonly listeners = new Set<(excluded: Excluded) => void>()
constructor(private readonly state: Pick<Memento, 'get' | 'update'>) {}
get excluded(): Excluded {
return normalizeExcluded(this.state.get(FILTER_KEY))
}
/** 筛选变了就调 listener,带上新的筛选。 */
subscribe(listener: (excluded: Excluded) => void): Disposable {
this.listeners.add(listener)
return { dispose: () => this.listeners.delete(listener) }
}
async set(value: unknown): Promise<void> {
const excluded = normalizeExcluded(value)
await this.state.update(FILTER_KEY, excluded)
for (const listener of [...this.listeners])
listener(excluded)
}
}
@@ -36,11 +36,15 @@ describe('预览的 document 消息', () => {
expect(message.error).toBeUndefined()
expect(message.blocks.map(b => b.kind)).toEqual(['if'])
expect(message.blocks[0].children).toMatchObject([{ kind: 'return', translation: [{ text: '返回' }, { text: ' ' }, { text: '空数组' }] }])
expect(message.attributes).toEqual([
{ id: 'kind', name: '种类', values: [{ id: 'if', name: '条件', count: 1 }, { id: 'return', name: '返回', count: 1 }] },
])
})
it('没有语言插件的语言:带 error,不解析', async () => {
const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime)
expect(message.blocks).toEqual([])
expect(message.attributes).toEqual([])
expect(message.translators).toEqual([])
expect(message.error).toContain('rust')
})
@@ -1,6 +1,7 @@
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { DocumentMessage } from './messages'
import { fileAttributes } from '../attributes'
import { readBlockViews } from '../blockView'
/** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */
@@ -24,6 +25,7 @@ export async function documentMessage(
version,
languageId,
blocks: [],
attributes: [],
translators: registry.translatorsFor(languageId).map(({ id, label }) => ({ id, label })),
translatorId: translator?.id ?? '',
}
@@ -31,7 +33,8 @@ export async function documentMessage(
if (!adapter)
return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }
try {
return { ...message, blocks: await readBlockViews(runtime, adapter, translator, languageId, source) }
const blocks = await readBlockViews(runtime, adapter, translator, languageId, source)
return { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) }
}
catch (err) {
return { ...message, error: err instanceof Error ? err.message : String(err) }
+10
View File
@@ -2,7 +2,11 @@
* 代码阅读预览与扩展之间的消息,与 webview 端 `webview-ui/src/codeReader/types.ts` 同构,两边手动保持一致。
*/
import type { FileAttribute } from '../attributes'
import type { BlockView } from '../blockView'
import type { Excluded } from './blockFilter'
export type { Excluded }
export interface TranslatorOption { id: string, label: string }
@@ -21,6 +25,8 @@ export interface DocumentMessage {
version: number
languageId: string
blocks: BlockView[]
/** 这份文件里出现过的筛选属性和值(带显示名、块数),按筛选栏的先后排,见 fileAttributes */
attributes: FileAttribute[]
translators: TranslatorOption[]
translatorId: string
error?: string
@@ -31,11 +37,13 @@ export interface DocumentMessage {
/**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义
* filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
*/
export type ExtensionToWebview
= | DocumentMessage
| ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'filter', excluded: Excluded }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
@@ -43,9 +51,11 @@ export type ExtensionToWebview
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去;
* version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
*/
export type WebviewToExtension
= | { type: 'ready' }
| { type: 'setFilter', excluded: Excluded }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
+14 -1
View File
@@ -1,6 +1,7 @@
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { BlockFilter } from './blockFilter'
import type { Definition } from './definitions'
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
@@ -29,13 +30,14 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
private readonly extensionUri: Uri,
private readonly registry: CodeReaderRegistry,
private readonly runtime: TreeSitterRuntime,
private readonly filter: BlockFilter,
) {}
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] }
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
const key = document.uri.toString()
const preview = new Preview(document, panel, this, this.registry, this.runtime, this.reveals.get(key))
const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key))
this.reveals.delete(key)
this.previews.add(preview)
panel.onDidDispose(() => {
@@ -96,6 +98,7 @@ class Preview {
private readonly owner: PreviewEditorProvider,
private readonly registry: CodeReaderRegistry,
private readonly runtime: TreeSitterRuntime,
private readonly filter: BlockFilter,
/**
* 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。
* 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复
@@ -112,6 +115,11 @@ class Preview {
if (e.document === document && e.contentChanges.length > 0)
this.schedulePush()
}),
// 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发
filter.subscribe((excluded) => {
if (this.ready)
this.post({ type: 'filter', excluded })
}),
]
}
@@ -167,8 +175,13 @@ class Preview {
switch (msg.type) {
case 'ready':
this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', excluded: this.filter.excluded })
await this.push()
return
case 'setFilter':
await this.filter.set(msg.excluded)
return
case 'edit':
await this.applyEdit(msg.startLine, msg.endLine, msg.text)
return