✨ feat(vscode): 代码阅读差异加「只看改动」(连着没变的块折成一条、按工作区记住)、上一处 / 下一处改动、右边缘总览条
This commit is contained in:
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 }])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user