From 6120c85bc0d9eb20267b70d10b6a43354d8bc5b6 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 08:54:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E5=B7=AE=E5=BC=82=E5=8A=A0=E3=80=8C=E5=8F=AA?= =?UTF-8?q?=E7=9C=8B=E6=94=B9=E5=8A=A8=E3=80=8D=EF=BC=88=E8=BF=9E=E7=9D=80?= =?UTF-8?q?=E6=B2=A1=E5=8F=98=E7=9A=84=E5=9D=97=E6=8A=98=E6=88=90=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E3=80=81=E6=8C=89=E5=B7=A5=E4=BD=9C=E5=8C=BA=E8=AE=B0?= =?UTF-8?q?=E4=BD=8F=EF=BC=89=E3=80=81=E4=B8=8A=E4=B8=80=E5=A4=84=20/=20?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E5=A4=84=E6=94=B9=E5=8A=A8=E3=80=81=E5=8F=B3?= =?UTF-8?q?=E8=BE=B9=E7=BC=98=E6=80=BB=E8=A7=88=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/codeReader/editor/blockFilter.test.ts | 35 ++++-- vscode/src/codeReader/editor/blockFilter.ts | 37 ++++-- vscode/src/codeReader/editor/messages.ts | 8 +- vscode/src/codeReader/editor/previewEditor.ts | 9 +- vscode/webview-ui/src/codeReader/App.tsx | 38 +++++- .../webview-ui/src/codeReader/BlockTree.tsx | 25 +++- vscode/webview-ui/src/codeReader/DiffBar.tsx | 39 +++++- .../src/codeReader/OverviewRuler.tsx | 48 ++++++++ .../webview-ui/src/codeReader/codeReader.css | 86 ++++++++++++++ .../src/codeReader/diffView.test.ts | 81 ++++++++++++- vscode/webview-ui/src/codeReader/diffView.ts | 111 ++++++++++++++++++ vscode/webview-ui/src/codeReader/types.ts | 6 +- .../webview-ui/src/codeReader/useChangeNav.ts | 50 ++++++++ vscode/webview-ui/src/codeReader/useReveal.ts | 2 +- 14 files changed, 533 insertions(+), 42 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/OverviewRuler.tsx create mode 100644 vscode/webview-ui/src/codeReader/useChangeNav.ts diff --git a/vscode/src/codeReader/editor/blockFilter.test.ts b/vscode/src/codeReader/editor/blockFilter.test.ts index 81b900f..6f3b687 100644 --- a/vscode/src/codeReader/editor/blockFilter.test.ts +++ b/vscode/src/codeReader/editor/blockFilter.test.ts @@ -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 = {}): Pick & { store: Record } { 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 }]) }) }) diff --git a/vscode/src/codeReader/editor/blockFilter.ts b/vscode/src/codeReader/editor/blockFilter.ts index c2bb986..9a07ad1 100644 --- a/vscode/src/codeReader/editor/blockFilter.ts +++ b/vscode/src/codeReader/editor/blockFilter.ts @@ -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 +/** 预览怎么藏块:块筛选(Excluded),和标差异时「只看改动」开没开(连着没变的块折成一条)。 */ +export interface FilterState { + excluded: Excluded + onlyChanges: boolean +} + function isRecord(value: unknown): value is Record { 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) {} - 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 { - 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 { + 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) } } diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 85d498e..ae8bcc3 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -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 } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 9081dea..6cdf28c 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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 diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 368b413..bd9c20b 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -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>(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 && } + below={diff && doc.error === undefined && ( + postMessage({ type: 'setOnlyChanges', on: !onlyChanges })} + canMove={stops.length > 0} + onMove={nav.move} + /> + )} /> + {changes && diff && }
{diff?.newFile !== undefined && doc.error === undefined && } {doc.error !== undefined && ( diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index c837279..5d2cac0 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 ( + + + + ) +} + /** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */ 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 => ( - {shown(block, tree) && ( + {tree.folded?.runs.has(block.id) && tree.onOpenRun(block.id)} />} + {shown(block, tree) && !tree.folded?.folded.has(block.id) && ( <> diff --git a/vscode/webview-ui/src/codeReader/DiffBar.tsx b/vscode/webview-ui/src/codeReader/DiffBar.tsx index dca51df..e3ffa2b 100644 --- a/vscode/webview-ui/src/codeReader/DiffBar.tsx +++ b/vscode/webview-ui/src/codeReader/DiffBar.tsx @@ -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 (
@@ -30,6 +41,28 @@ export function DiffBar({ diff }: { diff: DocumentDiff }): ReactElement { ))} + + + + {([[-1, ArrowUp, '上一处改动'], [1, ArrowDown, '下一处改动']] as const).map(([step, Icon, label]) => ( + + ))} + )}
diff --git a/vscode/webview-ui/src/codeReader/OverviewRuler.tsx b/vscode/webview-ui/src/codeReader/OverviewRuler.tsx new file mode 100644 index 0000000..77b78d9 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/OverviewRuler.tsx @@ -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('.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 ( + + ) +} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index a0d17e9..7311b1e 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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); +} diff --git a/vscode/webview-ui/src/codeReader/diffView.test.ts b/vscode/webview-ui/src/codeReader/diffView.test.ts index 5256fa7..313a60b 100644 --- a/vscode/webview-ui/src/codeReader/diffView.test.ts +++ b/vscode/webview-ui/src/codeReader/diffView.test.ts @@ -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 { 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> { + 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) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/diffView.ts b/vscode/webview-ui/src/codeReader/diffView.ts index 59b58d6..4ce85e7 100644 --- a/vscode/webview-ui/src/codeReader/diffView.ts +++ b/vscode/webview-ui/src/codeReader/diffView.ts @@ -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 + folded: ReadonlySet +} + +/** + * 「只看改动」:每一层连着没变的块折成一段(被筛掉的块不算、不打断),改过的块照常、它下面的子块同样按层折; + * 删掉的块排在哪块后面,那一段就在哪儿断开。opened 里的段(按第一块的 id,点开过的)不折;keep 里的块(跳到定义要露出来的) + * 和通向它的块不折。 + */ +export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, shown: (block: BlockView) => boolean, opened: ReadonlySet, keep: ReadonlySet): FoldedRuns { + const runs = new Map() + const folded = new Set() + 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): 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 +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 2be9e54..1e415ef 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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 } diff --git a/vscode/webview-ui/src/codeReader/useChangeNav.ts b/vscode/webview-ui/src/codeReader/useChangeNav.ts new file mode 100644 index 0000000..4e91b91 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useChangeNav.ts @@ -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, expand: (ids: string[]) => void): { + move: (step: 1 | -1) => void + goTo: (stop: ChangeStop) => void +} { + const [target, setTarget] = useState(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(`.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 } +} diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 90cfa03..fb8c5d6 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -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)