✨ feat(vscode): 代码阅读差异加「只看改动」(连着没变的块折成一条、按工作区记住)、上一处 / 下一处改动、右边缘总览条

This commit is contained in:
cruldra committed 2026-09-30 08:54:23 +08:00
1 parent 707d85c324
commit 6120c85bc0
14 files changed
+533 -42

No files matched your search

@@ -1,7 +1,7 @@
import type { Memento } from 'vscode'
import type { Excluded } from './blockFilter'
import type { FilterState } from './blockFilter'
import { describe, expect, it } from 'vitest'
import { BlockFilter, FILTER_KEY, normalizeExcluded } from './blockFilter'
import { BlockFilter, FILTER_KEY, normalizeExcluded, ONLY_CHANGES_KEY } from './blockFilter'
function memento(initial: Record<string, unknown> = {}): Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> } {
const store = { ...initial }
@@ -19,26 +19,41 @@ describe('块筛选:整个工作区共用一份', () => {
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'] })
expect(new BlockFilter(state).current.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))
const first: FilterState[] = []
const second: FilterState[] = []
filter.subscribe(state => first.push(state))
const off = filter.subscribe(state => second.push(state))
await filter.set({ async: ['true'] })
off.dispose()
await filter.set({})
expect(first).toEqual([{ async: ['true'] }, {}])
expect(second).toEqual([{ async: ['true'] }])
expect(first).toEqual([{ excluded: { async: ['true'] }, onlyChanges: false }, { excluded: {}, onlyChanges: false }])
expect(second).toEqual([{ excluded: { async: ['true'] }, onlyChanges: false }])
})
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({})
expect(new BlockFilter(memento({ [FILTER_KEY]: 'broken' })).current.excluded).toEqual({})
})
})
describe('只看改动:整个工作区共用一份', () => {
it('开着的记进 workspaceState,关了就不记;改了发给所有订阅的预览,带着眼下的块筛选', async () => {
const state = memento({ [FILTER_KEY]: { kind: ['comment'] } })
const filter = new BlockFilter(state)
const seen: FilterState[] = []
filter.subscribe(value => seen.push(value))
await filter.setOnlyChanges(true)
expect(state.store[ONLY_CHANGES_KEY]).toBe(true)
expect(new BlockFilter(state).current.onlyChanges).toBe(true)
await filter.setOnlyChanges(false)
expect(state.store[ONLY_CHANGES_KEY]).toBeUndefined()
expect(seen).toEqual([{ excluded: { kind: ['comment'] }, onlyChanges: true }, { excluded: { kind: ['comment'] }, onlyChanges: false }])
})
})
+28 -9
View File
@@ -2,6 +2,8 @@ import type { Disposable, Memento } from 'vscode'
/** workspaceState 里记的块筛选:Excluded。 */
export const FILTER_KEY = 'superpowers.codeReader.filter'
/** workspaceState 里记的「只看改动」:标差异时连着没变的块折起来。 */
export const ONLY_CHANGES_KEY = 'superpowers.codeReader.onlyChanges'
/**
* 块筛选:属性 id → 不勾(藏起来)的值 id。记的是去掉的值而不是勾着的值:
@@ -9,6 +11,12 @@ export const FILTER_KEY = 'superpowers.codeReader.filter'
*/
export type Excluded = Record<string, string[]>
/** 预览怎么藏块:块筛选(Excluded),和标差异时「只看改动」开没开(连着没变的块折成一条)。 */
export interface FilterState {
excluded: Excluded
onlyChanges: boolean
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -25,29 +33,40 @@ export function normalizeExcluded(value: unknown): Excluded {
}
/**
* 整个工作区共用的一份块筛选:记在 workspaceState 里,重新加载窗口后还在;
* 整个工作区共用的一份块筛选和「只看改动」开关:记在 workspaceState 里,重新加载窗口后还在;
* 一个预览改了,所有订阅的预览都收到(包括改的那个,webview 只按扩展端发来的显示)。
* 不依赖 vscode 运行时,便于单元测试。
*/
export class BlockFilter {
private readonly listeners = new Set<(excluded: Excluded) => void>()
private readonly listeners = new Set<(state: FilterState) => void>()
constructor(private readonly state: Pick<Memento, 'get' | 'update'>) {}
get excluded(): Excluded {
return normalizeExcluded(this.state.get(FILTER_KEY))
get current(): FilterState {
return { excluded: normalizeExcluded(this.state.get(FILTER_KEY)), onlyChanges: this.state.get(ONLY_CHANGES_KEY) === true }
}
/** 筛选变了就调 listener,带上新的筛选。 */
subscribe(listener: (excluded: Excluded) => void): Disposable {
/** 块筛选或「只看改动」变了就调 listener,带上整份新的。 */
subscribe(listener: (state: FilterState) => 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)
await this.state.update(FILTER_KEY, normalizeExcluded(value))
this.notify()
}
/** 打开 / 关掉「只看改动」。 */
async setOnlyChanges(on: boolean): Promise<void> {
await this.state.update(ONLY_CHANGES_KEY, on || undefined)
this.notify()
}
private notify(): void {
const state = this.current
for (const listener of [...this.listeners])
listener(excluded)
listener(state)
}
}
+5 -3
View File
@@ -9,7 +9,7 @@ import type { DocumentDiff } from '../diff/previewDiff'
import type { ShownName } from '../nameResolution'
import type { ProfileChoice } from '../qa/askProfiles'
import type { RunningAsk } from '../qa/asks'
import type { Excluded } from './blockFilter'
import type { Excluded, FilterState } from './blockFilter'
export type { Excluded, ShownName }
@@ -84,7 +84,7 @@ export interface QaMessage {
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
@@ -101,7 +101,7 @@ export type ExtensionToWebview
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'moveOccurrence', step: 1 | -1 }
| { type: 'displayNames', version: number, names: ShownName[] }
| { type: 'filter', excluded: Excluded }
| ({ type: 'filter' } & FilterState)
| QaMessage
| ({ type: 'askProfiles' } & ProfileChoice)
| { type: 'answer', askId: string, text: string, activity?: string }
@@ -116,6 +116,7 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* stopAsk:叫停 askId 那个问题,这次不存
@@ -127,6 +128,7 @@ export type ExtensionToWebview
export type WebviewToExtension
= | { type: 'ready' }
| { type: 'setFilter', excluded: Excluded }
| { type: 'setOnlyChanges', on: boolean }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
@@ -205,9 +205,9 @@ class Preview {
this.schedulePush()
}),
// 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发
filter.subscribe((excluded) => {
filter.subscribe((state) => {
if (this.ready)
this.post({ type: 'filter', excluded })
this.post({ type: 'filter', ...state })
}),
owner.qa.onDidChange((uris) => {
if (uris.some(uri => uri.toString() === document.uri.toString()))
@@ -427,7 +427,7 @@ class Preview {
case 'ready':
this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', excluded: this.filter.excluded })
this.post({ type: 'filter', ...this.filter.current })
this.postQa()
void this.postProfiles()
await this.push()
@@ -447,6 +447,9 @@ class Preview {
case 'setFilter':
await this.filter.set(msg.excluded)
return
case 'setOnlyChanges':
await this.filter.setOnlyChanges(msg.on)
return
case 'edit':
await this.applyEdit(msg.startLine, msg.endLine, msg.text)
return