✨ feat(vscode): 代码阅读差异加「只看改动」(连着没变的块折成一条、按工作区记住)、上一处 / 下一处改动、右边缘总览条
This commit is contained in:
1 parent
707d85c324
commit
6120c85bc0
14 files changed
+533
-42
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user