✨ 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
+32 -6
View File
@@ -8,11 +8,13 @@ import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BlockLevel } from './BlockTree'
import { DiffBar, NewFileBanner } from './DiffBar'
import { diffTree, openedByDiff } from './diffView'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
import { coveredByEditing, startEditing } from './editing'
import { applyFilter, excludedBy, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { OverviewRuler } from './OverviewRuler'
import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks'
import { HighlightContext, useOccurrences } from './useOccurrences'
import { useQa } from './useQa'
@@ -56,6 +58,16 @@ export function App(): ReactElement | null {
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds, openedByDiff(changes)), [doc, view.folds, changes])
const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep])
const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing])
/** 「只看改动」开着(整个工作区共用,扩展端记着);本地不改,改了发给扩展端、等它发回来 */
const [onlyChanges, setOnlyChanges] = useState(false)
/** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */
const [openedRuns, setOpenedRuns] = useState<ReadonlySet<string>>(NONE)
const folded = useMemo(() => doc && changes && onlyChanges
? foldUnchanged(doc.blocks, changes, block => !filter.hidden.has(block.id) && !covered.has(block.id), openedRuns, keep)
: undefined, [doc, changes, onlyChanges, filter, covered, openedRuns, keep])
/** 页面上没画出来的块:被筛掉的,和「只看改动」折起来的 */
const unshown = useMemo(() => folded ? new Set([...filter.hidden, ...folded.folded]) : filter.hidden, [filter, folded])
const stops = useMemo(() => doc && changes ? changeStops(doc.blocks, changes, filter.hidden) : [], [doc, changes, filter])
/** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */
const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
...prev,
@@ -65,16 +77,17 @@ export function App(): ReactElement | null {
const show = useCallback((ids: string[]) => setKeep(prev => new Set([...prev, ...ids])), [])
const revealed = useCallback(() => setReveal(null), [])
useScrollPosition(doc)
useReveal(doc, reveal, collapsed, filter.hidden, covered, show, expand, revealed)
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
useDefinitionLinks(doc?.version)
const nav = useChangeNav(stops, collapsed, expand)
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
? {
blocks: doc.blocks,
skip: id => filter.hidden.has(id) || covered.has(id) || view.editing.includes(id),
skip: id => unshown.has(id) || covered.has(id) || view.editing.includes(id),
collapsed,
expand,
}
: undefined, [doc, filter, covered, view.editing, collapsed, expand])
: undefined, [doc, unshown, covered, view.editing, collapsed, expand])
const highlight = useOccurrences(doc?.version, doc?.translatorId, occurrencePage)
useEffect(() => {
@@ -95,7 +108,9 @@ export function App(): ReactElement | null {
return
case 'filter':
setExcluded(msg.excluded)
setOnlyChanges(msg.onlyChanges)
setKeep(NONE)
setOpenedRuns(NONE)
return
case 'diff':
setDiff(msg.diff)
@@ -133,7 +148,9 @@ export function App(): ReactElement | null {
onToggleFold: id => setFolded([id], !collapsed.has(id)),
...(qa.state.available && { qa }),
...(changes && { diff: changes }),
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa, changes])
...(folded && { folded }),
onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])),
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa, changes, folded])
if (!doc || !tree)
return null
@@ -150,8 +167,17 @@ export function App(): ReactElement | null {
translators={doc.translators}
translatorId={doc.translatorId}
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
below={diff && doc.error === undefined && <DiffBar diff={diff} />}
below={diff && doc.error === undefined && (
<DiffBar
diff={diff}
onlyChanges={onlyChanges}
onToggleOnly={() => postMessage({ type: 'setOnlyChanges', on: !onlyChanges })}
canMove={stops.length > 0}
onMove={nav.move}
/>
)}
/>
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
{diff?.newFile !== undefined && doc.error === undefined && <NewFileBanner detail={diff.newFile} />}
{doc.error !== undefined && (
+21 -4
View File
@@ -1,9 +1,9 @@
import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { DiffTree, PlacedRemoved } from './diffView'
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
import type { QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { BlockMarks, BlockView } from './types'
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
import { Fragment, useState } from 'react'
import { AskThread } from './AskThread'
import { BlockTools } from './BlockTools'
@@ -38,6 +38,9 @@ export interface BlockTreeState {
qa?: QaTree
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
diff?: DiffTree
/** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged);onOpenRun 点开一段(按第一块的 id) */
folded?: FoldedRuns
onOpenRun: (id: string) => void
}
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
@@ -72,6 +75,19 @@ function ChangeRow({ tree, change, children }: { tree: BlockTreeState, change: k
)
}
/** 「只看改动」时连着没变的几块折成的一条「… N 块没变」,点开恢复这几块。 */
function UnchangedStrip({ count, tree, onOpen }: { count: number, tree: BlockTreeState, onOpen: () => void }): ReactElement {
return (
<ChangeRow tree={tree} change={undefined}>
<button type="button" className="cr-unchanged flex h-[22px] w-full items-center gap-1.5 rounded px-2.5 text-xs" onClick={onOpen}>
<ChevronsUpDown size={12} strokeWidth={1.8} className="shrink-0" aria-hidden />
{`… ${count} 块没变`}
<span className="cr-link">点开</span>
</button>
</ChangeRow>
)
}
/** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */
function shown(block: BlockView, tree: BlockTreeState): boolean {
return !tree.hidden.has(block.id) && !tree.covered.has(block.id)
@@ -93,7 +109,7 @@ function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-
/**
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
* owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard)。
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
*/
export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement {
const removed = tree.diff?.removed.get(owner?.id ?? '') ?? []
@@ -110,7 +126,8 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
{removedAfter(undefined)}
{blocks.map(block => (
<Fragment key={block.id}>
{shown(block, tree) && (
{tree.folded?.runs.has(block.id) && <UnchangedStrip count={tree.folded.runs.get(block.id) ?? 0} tree={tree} onOpen={() => tree.onOpenRun(block.id)} />}
{shown(block, tree) && !tree.folded?.folded.has(block.id) && (
<>
<ChangeRow tree={tree} change={tree.diff?.changes.get(block.id)?.change}>
<BlockCard block={block} tree={tree} />
+36 -3
View File
@@ -1,14 +1,25 @@
import type { ReactElement } from 'react'
import type { DocumentDiff } from './types'
import { FilePlus, GitCompare } from 'lucide-react'
import { ArrowDown, ArrowUp, FilePlus, GitCompare } from 'lucide-react'
import { Fragment } from 'react'
import { statsParts } from './diffView'
export interface DiffBarProps {
diff: DocumentDiff
/** 「只看改动」开着:连着没变的块折成一条 */
onlyChanges: boolean
onToggleOnly: () => void
/** 有没有改动可以跳(筛掉的不算) */
canMove: boolean
/** 跳到上一处(-1)/ 下一处(1)改动 */
onMove: (step: 1 | -1) => void
}
/**
* 筛选栏第二行(标差异时才有):对比基准(「对比:上一次提交」),改动统计(「新增 2 · 修改 3 · 删除 1」,各按改动色,
* 没有的那种不写,都没有写「没有改动」)。基准里没有这个文件时不写统计,看横幅。
* 没有的那种不写,都没有写「没有改动」),右边「只看改动」开关、上一处 / 下一处。基准里没有这个文件时只写基准,看横幅。
*/
export function DiffBar({ diff }: { diff: DocumentDiff }): ReactElement {
export function DiffBar({ diff, onlyChanges, onToggleOnly, canMove, onMove }: DiffBarProps): ReactElement {
const parts = statsParts(diff.stats)
return (
<div className="mx-auto box-content flex h-[30px] max-w-[880px] items-center gap-2 px-6 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
@@ -30,6 +41,28 @@ export function DiffBar({ diff }: { diff: DocumentDiff }): ReactElement {
</Fragment>
))}
</span>
<span className="flex-1" />
<button type="button" className="cr-only-changes flex h-6 items-center gap-1.5 rounded px-1.5" aria-pressed={onlyChanges} onClick={onToggleOnly}>
<span className="cr-switch" data-on={onlyChanges ? '' : undefined} aria-hidden>
<span className="cr-switch-knob" />
</span>
只看改动
</button>
<span className="flex items-center gap-0.5" role="group" aria-label="改动之间跳">
{([[-1, ArrowUp, '上一处改动'], [1, ArrowDown, '下一处改动']] as const).map(([step, Icon, label]) => (
<button
key={step}
type="button"
className="cr-tool flex h-[22px] w-6 items-center justify-center rounded disabled:opacity-40"
data-tooltip={label}
aria-label={label}
disabled={!canMove}
onClick={() => onMove(step)}
>
<Icon size={14} strokeWidth={1.8} className="text-[var(--vscode-foreground)]" aria-hidden />
</button>
))}
</span>
</>
)}
</div>
@@ -0,0 +1,48 @@
import type { ReactElement } from 'react'
import type { ChangeStop } from './diffView'
import { useLayoutEffect, useState } from 'react'
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
/** 钉在预览顶部的筛选栏有多高(它换行、多出第二行时跟着变):总览条从它下面开始。 */
function usePinnedHeight(): number {
const [height, setHeight] = useState(0)
useLayoutEffect(() => {
const bar = document.querySelector<HTMLElement>('.cr-filter-bar')
if (!bar)
return
setHeight(bar.offsetHeight)
const observer = new ResizeObserver(() => setHeight(bar.offsetHeight))
observer.observe(bar)
return () => observer.disconnect()
}, [])
return height
}
/**
* 预览右边缘的总览条:每处改动(见 ChangeStop)按它在文件里的行画一段刻度,新增绿、修改蓝、删除红(删掉的块一条细线),
* 点刻度跳过去。lineCount 是新文件一共几行。
*/
export function OverviewRuler({ stops, lineCount, onPick }: { stops: readonly ChangeStop[], lineCount: number, onPick: (stop: ChangeStop) => void }): ReactElement {
const top = usePinnedHeight()
const total = Math.max(lineCount, 1)
const percent = (line: number): string => `${(Math.min(Math.max(line, 0), total) / total) * 100}%`
return (
<nav className="cr-ruler" style={{ top }} aria-label="改动总览">
<div className="cr-ruler-track">
{stops.map(stop => (
<button
key={stop.id}
type="button"
className="cr-ruler-tick"
data-change={stop.change}
style={{ top: percent(stop.from), ...(stop.change !== 'del' && { height: percent(stop.to - stop.from + 1) }) }}
data-tooltip={`${CHANGE_NAMES[stop.change]} · 第 ${stop.from + 1} 行`}
aria-label={`${CHANGE_NAMES[stop.change]},第 ${stop.from + 1} 行`}
onClick={() => onPick(stop)}
/>
))}
</div>
</nav>
)
}
@@ -997,3 +997,89 @@ del.cr-diff-word {
}
.cr-diff-sign[data-change="add"] { color: var(--cr-diff-add); }
.cr-diff-sign[data-change="del"] { color: var(--cr-diff-del); }
/* 「只看改动」开关:一个小拨动开关加字,开着时底色同按下的工具按钮、开关换聚焦色 */
.cr-only-changes {
color: var(--vscode-foreground);
}
.cr-only-changes:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.cr-only-changes[aria-pressed="true"] {
background: var(--vscode-toolbar-activeBackground);
}
.cr-only-changes:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.cr-switch {
display: flex;
width: 26px;
height: 14px;
flex-shrink: 0;
align-items: center;
padding: 0 2px;
border: 1px solid var(--vscode-checkbox-border, var(--vscode-editorWidget-border, var(--vscode-panel-border)));
border-radius: 7px;
background: var(--vscode-input-background, transparent);
}
.cr-switch-knob {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--vscode-descriptionForeground);
}
.cr-switch[data-on] {
justify-content: flex-end;
border-color: var(--vscode-focusBorder);
background: var(--vscode-focusBorder);
}
.cr-switch[data-on] .cr-switch-knob {
background: #fff;
}
/* 「只看改动」时连着没变的块折成的一条:虚线框,点开恢复 */
.cr-unchanged {
border: 1px dashed var(--vscode-editorWidget-border, var(--vscode-panel-border));
color: var(--vscode-descriptionForeground);
}
.cr-unchanged:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.cr-unchanged:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
/* 预览右边缘的总览条:从筛选栏下面贴到底,每处改动按它在文件里的行画一段刻度(删掉的块一条细线),点刻度跳过去 */
.cr-ruler {
position: fixed;
right: 0;
bottom: 0;
z-index: 5;
width: 14px;
border-left: 1px solid var(--vscode-editorOverviewRuler-border, var(--vscode-panel-border, transparent));
background: var(--vscode-editor-background);
}
.cr-ruler-track {
position: absolute;
inset: 2px 0;
}
.cr-ruler-tick {
position: absolute;
left: 3px;
width: 8px;
min-height: 4px;
cursor: pointer;
}
.cr-ruler-tick[data-change="add"] { background: var(--cr-diff-add); }
.cr-ruler-tick[data-change="mod"] { background: var(--cr-diff-mod); }
.cr-ruler-tick[data-change="del"] {
min-height: 2px;
height: 2px;
background: var(--cr-diff-del);
}
.cr-ruler-tick:hover,
.cr-ruler-tick:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
}
@@ -1,6 +1,6 @@
import type { BlockView, DocumentDiff, RemovedView } from './types'
import { describe, expect, it } from 'vitest'
import { diffTree, openedByDiff, removedTitle, statsParts } from './diffView'
import { changeStops, diffTree, foldUnchanged, nextStop, openedByDiff, removedTitle, statsParts } from './diffView'
function block(id: string, anchor: string, children: BlockView[] = [], extra: Partial<BlockView> = {}): BlockView {
return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, children, translation: [], ...extra } as BlockView
@@ -69,3 +69,82 @@ describe('removedTitle', () => {
expect(removedTitle(block('0', 'x', [], { kind: 'comment', translation: { markdown: '说明第一行\n第二行' } }))).toBe('说明第一行')
})
})
/**
* 0 function a(修改)
* 0.0 import(没变)
* 0.1 return(修改,自己改了)
* 0.2 return(没变)
* 1 function b(没变)
* 2 function c(没变)
* 3 function d(新增,带子块 3.0)
* 4 function e(没变)
*/
const TREE_BLOCKS = [
block('0', 'a', [block('0.0', 'a0', [], { startLine: 1 }), block('0.1', 'a1', [], { startLine: 2, endLine: 2 }), block('0.2', 'a2', [], { startLine: 3 })], { startLine: 0, endLine: 3 }),
block('1', 'b', [], { startLine: 5 }),
block('2', 'c', [], { startLine: 7 }),
block('3', 'd', [block('3.0', 'd0')], { startLine: 9, endLine: 11 }),
block('4', 'e', [], { startLine: 13 }),
]
function tree(removedList: RemovedView[] = []): NonNullable<ReturnType<typeof diffTree>> {
return diffTree(TREE_BLOCKS, diff({
blocks: { a: { change: 'mod' }, a1: { change: 'mod', own: true }, d: { change: 'add', outermost: true }, d0: { change: 'add' } },
removed: removedList,
}), 2, () => true)!
}
describe('foldUnchanged', () => {
it('每层连着没变的块折成一段,改过的块下面同样按层折', () => {
const { runs, folded } = foldUnchanged(TREE_BLOCKS, tree(), () => true, new Set(), new Set())
expect([...runs]).toEqual([['0.0', 1], ['0.2', 1], ['1', 2], ['4', 1]])
expect([...folded].sort()).toEqual(['0.0', '0.2', '1', '2', '4'])
})
it('删掉的块排在哪块后面,那一段就在哪儿断开', () => {
const { runs } = foldUnchanged(TREE_BLOCKS, tree([removed('', 'b')]), () => true, new Set(), new Set())
expect([...runs]).toContainEqual(['1', 1])
expect([...runs]).toContainEqual(['2', 1])
})
it('被筛掉的块不算也不打断;点开过的段、要露出来的块不折', () => {
expect([...foldUnchanged(TREE_BLOCKS, tree(), block => block.id !== '2', new Set(), new Set()).runs]).toContainEqual(['1', 1])
expect(foldUnchanged(TREE_BLOCKS, tree(), () => true, new Set(['1']), new Set()).runs.has('1')).toBe(false)
expect(foldUnchanged(TREE_BLOCKS, tree(), () => true, new Set(), new Set(['2'])).folded.has('2')).toBe(false)
})
})
describe('changeStops', () => {
it('新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后;修改块只算自己那几行', () => {
const stops = changeStops(TREE_BLOCKS, tree([removed('', 'c'), removed('a', undefined)]), new Set())
expect(stops.map(stop => [stop.id, stop.change, stop.from, stop.to, stop.ancestors])).toEqual([
['removed-1', 'del', 3, 3, ['0']],
['0.1', 'mod', 2, 2, ['0']],
['removed-0', 'del', 3, 3, []],
['3', 'add', 9, 11, []],
])
})
it('被筛掉的块和它下面的不算', () => {
expect(changeStops(TREE_BLOCKS, tree(), new Set(['3', '3.0'])).map(stop => stop.id)).toEqual(['0.1'])
})
})
describe('nextStop', () => {
const stops = [{ from: 2 }, { from: 9 }, { from: 20 }].map((stop, index) => ({ id: String(index), change: 'mod' as const, to: stop.from, ancestors: [], ...stop }))
it('接着刚跳到的那处走,到头绕回另一头', () => {
expect(nextStop(stops, { index: 0 }, 1)).toBe(1)
expect(nextStop(stops, { index: 2 }, 1)).toBe(0)
expect(nextStop(stops, { index: 0 }, -1)).toBe(2)
})
it('按视口顶上那块的行找:下一处在它后面,上一处在它前面', () => {
expect(nextStop(stops, { line: 9 }, 1)).toBe(2)
expect(nextStop(stops, { line: 9 }, -1)).toBe(0)
expect(nextStop(stops, { line: 30 }, 1)).toBe(0)
expect(nextStop(stops, { line: 0 }, -1)).toBe(2)
expect(nextStop(stops, {}, 1)).toBe(0)
})
})
@@ -68,3 +68,114 @@ export function removedTitle(block: BlockView): string {
: 'markdown' in block.translation ? block.translation.markdown : inlineText(block.translation)
return (text ?? '').split('\n', 1)[0].trim()
}
/** 「只看改动」时折起来的:各段连着没变的块(按第一块的 id 记这段几块),和折进去的所有块(连同子孙)。 */
export interface FoldedRuns {
runs: ReadonlyMap<string, number>
folded: ReadonlySet<string>
}
/**
* 「只看改动」:每一层连着没变的块折成一段(被筛掉的块不算、不打断),改过的块照常、它下面的子块同样按层折;
* 删掉的块排在哪块后面,那一段就在哪儿断开。opened 里的段(按第一块的 id,点开过的)不折;keep 里的块(跳到定义要露出来的)
* 和通向它的块不折。
*/
export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, shown: (block: BlockView) => boolean, opened: ReadonlySet<string>, keep: ReadonlySet<string>): FoldedRuns {
const runs = new Map<string, number>()
const folded = new Set<string>()
const fold = (block: BlockView): void => {
folded.add(block.id)
block.children.forEach(fold)
}
const kept = (block: BlockView): boolean => keep.has(block.id) || block.children.some(kept)
const visit = (list: readonly BlockView[], owner: string): void => {
const breaks = new Set((tree.removed.get(owner) ?? []).map(item => item.after))
let run: BlockView[] = []
const close = (): void => {
if (run.length > 0 && !opened.has(run[0].id)) {
runs.set(run[0].id, run.length)
run.forEach(fold)
}
run = []
}
for (const block of list) {
if (shown(block)) {
if (tree.changes.has(block.id) || kept(block)) {
close()
visit(block.children, block.id)
}
else {
run.push(block)
}
}
if (breaks.has(block.id))
close()
}
close()
}
visit(blocks, '')
return { runs, folded }
}
/**
* 「上一处 / 下一处」和总览条上的一处改动:新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后。
* id 是页面上的块 id(删掉的块是 PlacedRemoved.id);from / to 是它在新文件里的行(0 起),修改块只算它自己那几行(到第一个子块之前);
* ancestors 是要展开才看得见它的各层块。
*/
export interface ChangeStop {
id: string
change: 'add' | 'mod' | 'del'
from: number
to: number
ancestors: string[]
}
/** 页面上的各处改动(见 ChangeStop);hidden 里的块(被筛掉的)连同子孙不算。 */
export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden: ReadonlySet<string>): ChangeStop[] {
const stops: ChangeStop[] = []
const visit = (list: readonly BlockView[], owner: string, ancestors: string[]): void => {
const removed = tree.removed.get(owner) ?? []
const removedAfter = (id: string | undefined): void => {
for (const item of removed.filter(entry => entry.after === id))
stops.push({ id: item.id, change: 'del', from: item.removed.line, to: item.removed.line, ancestors })
}
removedAfter(undefined)
for (const block of list) {
const change = hidden.has(block.id) ? undefined : tree.changes.get(block.id)
if (change?.change === 'add' && change.outermost)
stops.push({ id: block.id, change: 'add', from: block.startLine, to: block.endLine, ancestors })
if (change?.change === 'mod' && change.own) {
const own = block.children[0] ? Math.max(block.startLine, block.children[0].startLine - 1) : block.endLine
stops.push({ id: block.id, change: 'mod', from: block.startLine, to: own, ancestors })
}
if (change?.change === 'mod')
visit(block.children, block.id, [...ancestors, block.id])
removedAfter(block.id)
}
}
visit(blocks, '', [])
return stops
}
/**
* 「上一处 / 下一处」跳到第几处:at.index 是接着走的那处(刚跳到的那处还在视口顶上时),否则按视口顶上那块的行 at.line 找:
* 下一处是起始行在它后面的第一处,上一处是在它前面的最后一处;走到头绕回另一头。
*/
export function nextStop(stops: readonly ChangeStop[], at: { index?: number, line?: number }, step: 1 | -1): number {
const count = stops.length
if (at.index !== undefined)
return (at.index + step + count) % count
const line = at.line
if (line === undefined)
return step === 1 ? 0 : count - 1
if (step === 1) {
const index = stops.findIndex(stop => stop.from > line)
return index >= 0 ? index : 0
}
let last = -1
stops.forEach((stop, index) => {
if (stop.from < line)
last = index
})
return last >= 0 ? last : count - 1
}
+4 -2
View File
@@ -352,7 +352,7 @@ export interface DocumentDiff {
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
@@ -368,7 +368,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', excluded: Excluded, onlyChanges: boolean }
| QaMessage
| ({ type: 'askProfiles' } & ProfileChoice)
| { type: 'answer', askId: string, text: string, activity?: string }
@@ -383,6 +383,7 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
* setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* stopAsk:叫停 askId 那个问题,这次不存
@@ -393,6 +394,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 }
@@ -0,0 +1,50 @@
import type { ChangeStop } from './diffView'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { nextStop } from './diffView'
import { anchorAt } from './scrollPosition'
import { flash } from './useReveal'
import { measureBlocks, pinnedHeight } from './useScrollPosition'
/** 跳到的那处离筛选栏下沿留多少像素。 */
const MARGIN = 8
/**
* 「上一处 / 下一处」和点总览条的刻度:跳到一处改动。它在折叠着的块里时先展开那几层(expand),渲染好了再把它滚到筛选栏下面、闪几下。
* 接着走从哪算:跳完之后页面没滚过就从刚跳到的那处接着走,用户自己滚过了就按视口顶上那块的行找(见 nextStop)。
*/
export function useChangeNav(stops: readonly ChangeStop[], collapsed: ReadonlySet<string>, expand: (ids: string[]) => void): {
move: (step: 1 | -1) => void
goTo: (stop: ChangeStop) => void
} {
const [target, setTarget] = useState<ChangeStop | null>(null)
/** 最近一次跳到的那处,和跳完时页面滚到哪 */
const jumped = useRef<{ id: string, scrollY: number } | null>(null)
useLayoutEffect(() => {
if (!target)
return
const hidden = target.ancestors.filter(id => collapsed.has(id))
if (hidden.length > 0) {
expand(hidden)
return
}
const card = document.querySelector<HTMLElement>(`.cr-block[data-id="${target.id}"]`)
if (card) {
window.scrollTo(0, card.getBoundingClientRect().top + window.scrollY - pinnedHeight() - MARGIN)
flash(card)
}
jumped.current = { id: target.id, scrollY: window.scrollY }
setTarget(null)
}, [target, collapsed, expand])
const move = useCallback((step: 1 | -1) => {
if (stops.length === 0)
return
const from = jumped.current && Math.abs(window.scrollY - jumped.current.scrollY) < 2 ? jumped.current.id : undefined
const index = from === undefined ? -1 : stops.findIndex(stop => stop.id === from)
const at = index >= 0 ? { index } : { line: anchorAt(measureBlocks(), window.scrollY + pinnedHeight())?.line }
setTarget(stops[nextStop(stops, at, step)])
}, [stops])
return { move, goTo: setTarget }
}
@@ -6,7 +6,7 @@ import { blockForLine } from './scrollPosition'
import { measureBlocks, pinnedHeight } from './useScrollPosition'
/** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */
function flash(el: HTMLElement): void {
export function flash(el: HTMLElement): void {
const running = el.getAnimations().filter(animation => animation instanceof CSSAnimation)
if (running.length > 0) {
for (const animation of running)