From fa418d1533c441c82f9841ff7ab2ac1f43a30a03 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 14:12:16 +0200 Subject: [PATCH 1/2] feat(diff): highlight where the walkthrough points, dim what a rule can prove is mechanical Attention is the scarce thing in a review, and a diff spends it evenly. Two changes, with the decider deliberately different for each. Highlighting comes from the walkthrough: a step already records the lines it is about, so the hunks covering them are tinted. Only ever added attention, never removed. Dimming is decided by rules, never by a model, because dimming asserts that something needs less attention - the one claim a review tool should have to show its reasoning for. A hunk is dimmed when it is entirely imports, when its added and removed lines are the same lines with different whitespace, or when the file is generated. It recedes to 45% and returns on hover, stays selectable and commentable, and carries the reason as a title so a reader can always find out why rather than having to trust it. Indentation-sensitive files are never whitespace-dimmed. In YAML, Python, Makefiles and markdown the indentation is the syntax, so a reindent there can change behaviour. Whitespace hiding now defaults on and is remembered, since that is the formatter's business - and the toolbar says "whitespace hidden" whenever it is, because a filtered diff renders fewer lines than the forge does and that must not be silent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- packages/ui/src/components/diff/diff-page.tsx | 16 +- packages/ui/src/components/diff/diff-view.tsx | 4 + .../ui/src/components/diff/file-block.tsx | 31 +++- .../src/components/diff/hunk-block-split.tsx | 10 +- .../ui/src/components/diff/hunk-block.tsx | 10 +- .../ui/src/components/diff/hunk-with-gap.tsx | 7 +- packages/ui/src/hooks/use-hide-whitespace.ts | 26 +++ packages/ui/src/lib/diff-utils.ts | 2 +- packages/ui/src/lib/hunk-attention.ts | 144 ++++++++++++++++ packages/ui/tests/hunk-attention.test.ts | 158 ++++++++++++++++++ 10 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/hooks/use-hide-whitespace.ts create mode 100644 packages/ui/src/lib/hunk-attention.ts create mode 100644 packages/ui/tests/hunk-attention.test.ts diff --git a/packages/ui/src/components/diff/diff-page.tsx b/packages/ui/src/components/diff/diff-page.tsx index 0325f5a..7684d8c 100644 --- a/packages/ui/src/components/diff/diff-page.tsx +++ b/packages/ui/src/components/diff/diff-page.tsx @@ -8,6 +8,7 @@ import { useWrapLines } from '../../hooks/use-wrap-lines'; import { useKeyboard } from '../../hooks/use-keyboard'; import { useReviewThreads } from '../../hooks/use-review-threads'; import { useTours } from '../../hooks/use-tours'; +import { useHideWhitespace } from '../../hooks/use-hide-whitespace'; import { pickActiveTour, orderPathsByTour, stopsByPath } from '../../lib/tour-order'; import { TourStepper } from './tour-stepper'; import { useCommentActions } from '../../hooks/use-comment-actions'; @@ -41,7 +42,7 @@ export function DiffPage() { }>(); const [viewMode, setViewMode] = useState(initialViewMode || 'split'); - const [hideWhitespace, setHideWhitespace] = useState(false); + const { hideWhitespace, setHideWhitespace } = useHideWhitespace(); const [showHelp, setShowHelp] = useState(false); const { theme, toggleTheme } = useTheme(initialTheme); const { wrapLines, toggleWrapLines } = useWrapLines(); @@ -85,6 +86,16 @@ export function DiffPage() { const diffPaths = useMemo(() => (diff ? diff.files.map(file => getFilePath(file)) : []), [diff]); const tourStops = useMemo(() => stopsByPath(activeTour, diffPaths), [activeTour, diffPaths]); + const focusRangesByFile = useMemo(() => { + const ranges = new Map(); + for (const step of activeTour?.steps ?? []) { + const existing = ranges.get(step.filePath) ?? []; + existing.push({ startLine: step.startLine, endLine: step.endLine }); + ranges.set(step.filePath, existing); + } + return ranges; + }, [activeTour]); + const orderedDiff = useMemo(() => { if (!diff || !activeTour || !reviewOrderEnabled || activeTour.steps.length === 0) { return diff; @@ -404,7 +415,7 @@ export function DiffPage() { onScrollToThread={handleScrollToThread} repoName={info?.name || null} branch={info?.branch || null} - description={info?.description || null} + description={hideWhitespace ? `${info?.description ?? ''} ยท whitespace hidden` : info?.description || null} githubDetails={githubDetails} sessionId={sessionId} onGitHubPulled={() => queryClient.invalidateQueries({ queryKey: ['threads'] })} @@ -458,6 +469,7 @@ export function DiffPage() { onAddThread={handleAddThread} pendingSelection={pendingSelection} onPendingSelectionChange={setPendingSelection} + focusRangesByFile={focusRangesByFile} /> ) : null} diff --git a/packages/ui/src/components/diff/diff-view.tsx b/packages/ui/src/components/diff/diff-view.tsx index 8fcbb61..ff4b581 100644 --- a/packages/ui/src/components/diff/diff-view.tsx +++ b/packages/ui/src/components/diff/diff-view.tsx @@ -47,6 +47,8 @@ interface DiffViewProps { onAddThread: CommentActions['addThread']; pendingSelection: LineSelection | null; onPendingSelectionChange: (selection: LineSelection | null) => void; + /** Per file, the line ranges the walkthrough points at. */ + focusRangesByFile?: Map; } function estimateFileHeight(file: { hunks: { lines: { length: number } }[]; isBinary: boolean }, collapsed: boolean): number { @@ -73,6 +75,7 @@ export function DiffView(props: DiffViewProps) { handle, baseRef, canRevert, onRevert, threads, commentsEnabled, commentActions, onAddThread, pendingSelection, onPendingSelectionChange, + focusRangesByFile, } = props; const { highlight } = useHighlighter(); const scrollElementRef = useRef(null); @@ -277,6 +280,7 @@ export function DiffView(props: DiffViewProps) { ref={virtualizer.measureElement} > { if (highlightedFile === filePath) { diff --git a/packages/ui/src/components/diff/file-block.tsx b/packages/ui/src/components/diff/file-block.tsx index 4070d14..f20c65d 100644 --- a/packages/ui/src/components/diff/file-block.tsx +++ b/packages/ui/src/components/diff/file-block.tsx @@ -6,6 +6,7 @@ import type { SyntaxToken } from '../../lib/syntax-token'; import type { HighlightedTokens } from '../../hooks/use-highlighter'; import type { CommentSide, LineSelection } from '../comments/types'; import { type ViewMode, getFilePath, buildChangeGroupPatch, extractLinesFromDiff, extractLinesFromExpandedLines } from '../../lib/diff-utils'; +import { classifyHunk, hunkIntersectsRanges, MECHANICAL_LABELS } from '../../lib/hunk-attention'; import { revertHunk as apiRevertHunk } from '../../lib/api'; import { ConfirmDialog } from '../ui/confirm-dialog'; import { computeGaps, createContextLines, getExpandRange, type ExpandableGap } from '../../lib/context-expansion'; @@ -58,6 +59,8 @@ interface FileBlockProps { onPendingSelectionChange: (selection: LineSelection | null) => void; highlighted?: boolean; onHighlightEnd?: () => void; + /** Line ranges the walkthrough asked the reader to look at, on the new side. */ + focusRanges?: { startLine: number; endLine: number }[]; } interface GapExpansion { @@ -69,7 +72,7 @@ interface GapExpansion { export function FileBlock(props: FileBlockProps) { const { - file, viewMode, collapsed, onToggleCollapse, reviewed, onReviewedChange, highlightLine, baseRef, canRevert, onRevert, + file, viewMode, collapsed, onToggleCollapse, reviewed, onReviewedChange, highlightLine, baseRef, canRevert, onRevert, focusRanges, threads: allThreads, commentsEnabled, commentActions, onAddThread: rawAddThread, pendingSelection, onPendingSelectionChange, highlighted, onHighlightEnd, } = props; @@ -257,7 +260,17 @@ export function FileBlock(props: FileBlockProps) { requestAnimationFrame(processChunk); - return () => { + const hunkAttention = useMemo( + () => file.hunks.map(hunk => classifyHunk(file, hunk)), + [file], + ); + + const isHunkFocused = useCallback( + (hunk: typeof file.hunks[number]) => hunkIntersectsRanges(hunk, focusRanges), + [focusRanges], + ); + + return () => { cancelled = true; }; }, [file, highlightLine]); @@ -502,6 +515,18 @@ export function FileBlock(props: FileBlockProps) { )} {file.hunks.map((hunk, i) => { + const mechanical = hunkAttention[i]; + const focused = isHunkFocused(hunk); + const attentionClass = focused + ? 'bg-accent/5' + : mechanical + ? 'opacity-45 hover:opacity-100 transition-opacity' + : ''; + const attentionTitle = focused + ? 'The walkthrough points here' + : mechanical + ? `Dimmed: ${MECHANICAL_LABELS[mechanical]}` + : undefined; const betweenGap = i > 0 ? gapMap.get(`between-${i - 1}`) : undefined; const betweenExpansion = betweenGap ? expansions.get(betweenGap.id) : undefined; const topExpansion = i === 0 ? expansions.get('top') : undefined; @@ -510,6 +535,8 @@ export function FileBlock(props: FileBlockProps) { ; expandControls?: ExpandControls; topExpansionLines?: DiffLineType[]; @@ -345,7 +349,7 @@ export function HunkBlockSplit(props: HunkBlockSplitProps) { if (isChangeGroup && onRevertChange) { const group = changeGroups[groupIdx]; sections.push( - + {currentRows} @@ -365,7 +369,7 @@ export function HunkBlockSplit(props: HunkBlockSplitProps) { ); } else { sections.push( - + {currentRows} ); @@ -418,7 +422,7 @@ export function HunkBlockSplit(props: HunkBlockSplitProps) { return ( <> - + {expansionRows} diff --git a/packages/ui/src/components/diff/hunk-block.tsx b/packages/ui/src/components/diff/hunk-block.tsx index 2546401..8471bbc 100644 --- a/packages/ui/src/components/diff/hunk-block.tsx +++ b/packages/ui/src/components/diff/hunk-block.tsx @@ -11,6 +11,10 @@ import { UndoIcon } from '../icons/undo-icon'; interface HunkBlockProps { hunk: DiffHunk; + /** Applied to every row group of the hunk, so a whole hunk recedes or stands out together. */ + attentionClass?: string; + /** Why it was dimmed, so the reader can always find out rather than having to trust it. */ + attentionTitle?: string; syntaxMap?: Map; expandControls?: ExpandControls; topExpansionLines?: DiffLineType[]; @@ -163,7 +167,7 @@ export function HunkBlock(props: HunkBlockProps) { if (isChangeGroup && onRevertChange) { const group = changeGroups[groupIdx]; sections.push( - + {currentRows} @@ -183,7 +187,7 @@ export function HunkBlock(props: HunkBlockProps) { ); } else { sections.push( - + {currentRows} ); @@ -216,7 +220,7 @@ export function HunkBlock(props: HunkBlockProps) { return ( <> - + {expansionRows} diff --git a/packages/ui/src/components/diff/hunk-with-gap.tsx b/packages/ui/src/components/diff/hunk-with-gap.tsx index 0624779..3e212bf 100644 --- a/packages/ui/src/components/diff/hunk-with-gap.tsx +++ b/packages/ui/src/components/diff/hunk-with-gap.tsx @@ -17,6 +17,8 @@ interface GapExpansion { interface HunkWithGapProps { hunk: DiffHunk; + attentionClass?: string; + attentionTitle?: string; viewMode: ViewMode; syntaxMap?: Map; expandControls?: ExpandControls; @@ -46,7 +48,8 @@ interface HunkWithGapProps { export function HunkWithGap(props: HunkWithGapProps) { const { - hunk, viewMode, syntaxMap, expandControls, topExpansionLines, gapExpansion, gapId, highlightLine, + hunk, + attentionClass, attentionTitle, viewMode, syntaxMap, expandControls, topExpansionLines, gapExpansion, gapId, highlightLine, threads, pendingSelection, currentAuthor, isLineSelected, onLineMouseDown, onLineMouseEnter, onCommentClick, onAddThread, onReply, onResolve, onUnresolve, onEditComment, onDeleteComment, onDeleteThread, @@ -86,6 +89,8 @@ export function HunkWithGap(props: HunkWithGapProps) { )} 0 ? topExpansionLines : undefined} diff --git a/packages/ui/src/hooks/use-hide-whitespace.ts b/packages/ui/src/hooks/use-hide-whitespace.ts new file mode 100644 index 0000000..1e9fe64 --- /dev/null +++ b/packages/ui/src/hooks/use-hide-whitespace.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react'; + +const STORAGE_KEY = 'diffity-hide-whitespace'; + +/** + * On by default: whitespace is the formatter's business, so it should not spend a reviewer's + * attention. The toolbar shows when it is on, because a filtered diff renders fewer lines than + * the forge does and that must never be silent. + */ +function readStored(): boolean { + if (typeof window === 'undefined') { + return true; + } + return localStorage.getItem(STORAGE_KEY) !== 'false'; +} + +export function useHideWhitespace() { + const [hideWhitespace, setHideWhitespace] = useState(readStored); + + const setAndRemember = useCallback((hide: boolean) => { + setHideWhitespace(hide); + localStorage.setItem(STORAGE_KEY, String(hide)); + }, []); + + return { hideWhitespace, setHideWhitespace: setAndRemember }; +} diff --git a/packages/ui/src/lib/diff-utils.ts b/packages/ui/src/lib/diff-utils.ts index dbd7876..e287144 100644 --- a/packages/ui/src/lib/diff-utils.ts +++ b/packages/ui/src/lib/diff-utils.ts @@ -88,7 +88,7 @@ function getTotalLineCount(file: DiffFile): number { return count; } -function isAutoCollapsible(file: DiffFile): boolean { +export function isAutoCollapsible(file: DiffFile): boolean { if (file.status === 'deleted' || file.status === 'renamed') { return true; } diff --git a/packages/ui/src/lib/hunk-attention.ts b/packages/ui/src/lib/hunk-attention.ts new file mode 100644 index 0000000..2335191 --- /dev/null +++ b/packages/ui/src/lib/hunk-attention.ts @@ -0,0 +1,144 @@ +import type { DiffFile, DiffHunk, DiffLine } from '@diffity/parser'; +import { isAutoCollapsible } from './diff-utils'; + +/** + * Why a hunk was judged mechanical. Every reason here is decided by a rule, never by a model: + * dimming asserts that something needs less attention, which is the one claim a review tool + * should only make when it can show its reasoning. + */ +export type MechanicalReason = 'generated' | 'whitespace' | 'imports'; + +export const MECHANICAL_LABELS: Record = { + generated: 'generated', + whitespace: 'whitespace only', + imports: 'imports only', +}; + +// Indentation is syntax in these, so a whitespace change is a real change and must not be dimmed. +const INDENTATION_SENSITIVE = [ + '.py', + '.pyi', + '.yml', + '.yaml', + '.md', + '.markdown', + '.mk', + '.nim', + '.hs', + '.sass', + '.styl', + '.pug', + '.haml', + '.slim', + '.coffee', + '.elm', +]; + +const IMPORT_PATTERNS = [ + /^\s*import\b/, + /^\s*export\s[^=]*\bfrom\b/, + /^\s*(?:const|let|var)\s+.*=\s*require\s*\(/, + /^\s*from\s+\S+\s+import\b/, + /^\s*use\s+[\w:{}*, ]+;\s*$/, + /^\s*#include\b/, + /^\s*require(?:_relative)?\s+['"]/, + /^\s*package\s+\S+\s*;?\s*$/, +]; + +export function isIndentationSensitive(path: string): boolean { + const lower = path.toLowerCase(); + if (lower.endsWith('makefile') || lower.includes('makefile.')) { + return true; + } + return INDENTATION_SENSITIVE.some(ext => lower.endsWith(ext)); +} + +function changedLines(hunk: DiffHunk): DiffLine[] { + return hunk.lines.filter(line => line.type === 'add' || line.type === 'delete'); +} + +function withoutWhitespace(content: string): string { + return content.replace(/\s+/g, ''); +} + +/** + * True when the added and removed lines are the same lines with different whitespace. A blank + * line that appeared or disappeared is a real change to the file's shape, so it does not count. + */ +export function isWhitespaceOnlyHunk(hunk: DiffHunk): boolean { + const changed = changedLines(hunk); + if (changed.length === 0) { + return false; + } + + const added = changed.filter(line => line.type === 'add').map(line => withoutWhitespace(line.content)).sort(); + const removed = changed.filter(line => line.type === 'delete').map(line => withoutWhitespace(line.content)).sort(); + + return ( + added.length > 0 && + added.length === removed.length && + added.every((line, index) => line === removed[index]) + ); +} + +/** + * True when every line the hunk touches is an import. A linter will catch an unused or unsorted + * import, but not a wrong one, so this dims rather than hides. + */ +export function isImportOnlyHunk(hunk: DiffHunk): boolean { + const changed = changedLines(hunk); + if (changed.length === 0) { + return false; + } + + let sawImport = false; + for (const line of changed) { + if (line.content.trim() === '') { + continue; + } + if (!IMPORT_PATTERNS.some(pattern => pattern.test(line.content))) { + return false; + } + sawImport = true; + } + + return sawImport; +} + +export function classifyHunk(file: DiffFile, hunk: DiffHunk): MechanicalReason | null { + if (isAutoCollapsible(file)) { + return 'generated'; + } + + const path = file.status === 'deleted' ? file.oldPath : file.newPath; + + if (!isIndentationSensitive(path) && isWhitespaceOnlyHunk(hunk)) { + return 'whitespace'; + } + + if (isImportOnlyHunk(hunk)) { + return 'imports'; + } + + return null; +} + +export interface FocusRange { + startLine: number; + endLine: number; +} + +/** + * Whether a hunk covers any of the lines the walkthrough points at. Compared on the new side, + * which is where a walkthrough step's lines are recorded. + */ +export function hunkIntersectsRanges(hunk: DiffHunk, ranges: FocusRange[] | undefined): boolean { + if (!ranges || ranges.length === 0) { + return false; + } + + const from = hunk.newStart; + const to = hunk.newStart + hunk.newCount - 1; + + return ranges.some(range => range.startLine <= to && range.endLine >= from); +} diff --git a/packages/ui/tests/hunk-attention.test.ts b/packages/ui/tests/hunk-attention.test.ts new file mode 100644 index 0000000..ca2c7bb --- /dev/null +++ b/packages/ui/tests/hunk-attention.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import type { DiffFile, DiffHunk, DiffLine } from '@diffity/parser'; +import { + classifyHunk, + hunkIntersectsRanges, + isImportOnlyHunk, + isIndentationSensitive, + isWhitespaceOnlyHunk, +} from '../src/lib/hunk-attention'; + +function line(type: DiffLine['type'], content: string): DiffLine { + return { type, content, oldLineNumber: null, newLineNumber: null }; +} + +function hunk(lines: DiffLine[]): DiffHunk { + return { header: '@@ -1,3 +1,3 @@', oldStart: 1, oldCount: 3, newStart: 1, newCount: 3, lines }; +} + +function file(path: string, hunks: DiffHunk[] = []): DiffFile { + return { + oldPath: path, + newPath: path, + status: 'modified', + hunks, + additions: 1, + deletions: 1, + isBinary: false, + }; +} + +describe('isWhitespaceOnlyHunk', () => { + it('sees a reindent', () => { + expect( + isWhitespaceOnlyHunk(hunk([line('delete', ' return 1;'), line('add', ' return 1;')])), + ).toBe(true); + }); + + it('does not see a real edit', () => { + expect( + isWhitespaceOnlyHunk(hunk([line('delete', ' return 1;'), line('add', ' return 2;')])), + ).toBe(false); + }); + + it('does not treat an added blank line as whitespace-only', () => { + expect(isWhitespaceOnlyHunk(hunk([line('add', '')]))).toBe(false); + }); + + it('does not treat a pure deletion as whitespace-only', () => { + expect(isWhitespaceOnlyHunk(hunk([line('delete', ' gone();')]))).toBe(false); + }); + + it('ignores context lines', () => { + expect( + isWhitespaceOnlyHunk( + hunk([line('context', 'unchanged'), line('delete', 'a( b )'), line('add', 'a(b)')]), + ), + ).toBe(true); + }); +}); + +describe('isImportOnlyHunk', () => { + it('sees es imports, requires, python, rust, c and go', () => { + const cases = [ + "import { a } from 'a';", + "export { b } from './b';", + "const c = require('c');", + 'from pathlib import Path', + 'use std::fs::read_to_string;', + '#include ', + 'package main', + ]; + + for (const content of cases) { + expect(isImportOnlyHunk(hunk([line('add', content)])), content).toBe(true); + } + }); + + it('tolerates blank lines among the imports', () => { + expect( + isImportOnlyHunk(hunk([line('add', "import { a } from 'a';"), line('add', '')])), + ).toBe(true); + }); + + it('refuses when anything else changed', () => { + expect( + isImportOnlyHunk(hunk([line('add', "import { a } from 'a';"), line('add', 'doWork();')])), + ).toBe(false); + }); + + it('refuses a hunk with no changed lines', () => { + expect(isImportOnlyHunk(hunk([line('context', "import { a } from 'a';")]))).toBe(false); + }); +}); + +describe('isIndentationSensitive', () => { + it('knows where whitespace is syntax', () => { + for (const path of ['a.py', 'ci.yml', 'k8s.yaml', 'README.md', 'Makefile', 'x.hs']) { + expect(isIndentationSensitive(path), path).toBe(true); + } + }); + + it('leaves brace languages alone', () => { + for (const path of ['a.ts', 'b.tsx', 'c.go', 'd.java', 'e.css']) { + expect(isIndentationSensitive(path), path).toBe(false); + } + }); +}); + +describe('classifyHunk', () => { + it('dims a generated file whatever is in it', () => { + const generated = file('pnpm-lock.yaml'); + + expect(classifyHunk(generated, hunk([line('add', 'anything')]))).toBe('generated'); + }); + + it('dims a reindent in a brace language', () => { + expect( + classifyHunk(file('a.ts'), hunk([line('delete', 'if(x){'), line('add', 'if (x) {')])), + ).toBe('whitespace'); + }); + + it('refuses to dim a reindent where indentation is syntax', () => { + const reindent = hunk([line('delete', ' key: value'), line('add', ' key: value')]); + + expect(classifyHunk(file('ci.yml'), reindent)).toBeNull(); + }); + + it('dims an imports-only hunk', () => { + expect(classifyHunk(file('a.ts'), hunk([line('add', "import { a } from 'a';")]))).toBe('imports'); + }); + + it('leaves real work alone', () => { + expect(classifyHunk(file('a.ts'), hunk([line('add', 'doWork();')]))).toBeNull(); + }); +}); + +describe('hunkIntersectsRanges', () => { + const target = { ...hunk([]), newStart: 10, newCount: 5 }; // lines 10-14 + + it('matches a range inside the hunk', () => { + expect(hunkIntersectsRanges(target, [{ startLine: 12, endLine: 12 }])).toBe(true); + }); + + it('matches a range straddling either edge', () => { + expect(hunkIntersectsRanges(target, [{ startLine: 5, endLine: 10 }])).toBe(true); + expect(hunkIntersectsRanges(target, [{ startLine: 14, endLine: 30 }])).toBe(true); + }); + + it('does not match a range that misses by one', () => { + expect(hunkIntersectsRanges(target, [{ startLine: 1, endLine: 9 }])).toBe(false); + expect(hunkIntersectsRanges(target, [{ startLine: 15, endLine: 20 }])).toBe(false); + }); + + it('is false without ranges', () => { + expect(hunkIntersectsRanges(target, [])).toBe(false); + expect(hunkIntersectsRanges(target, undefined)).toBe(false); + }); +}); From a8d4926c93aaedc1979d7e374a8756d87e5e8503 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 14:39:58 +0200 Subject: [PATCH 2/2] fix(diff): bind the attention props, and render components in tests The attention props were declared on the interfaces and used in the JSX but never destructured, so every diff threw ReferenceError: attentionClass is not defined at render. A scripted edit had looked for a two-line destructure where the file has a one-line one and silently matched nothing. Neither `vite build` nor tsc caught it, and nothing else stood between a render-time ReferenceError and the browser. The UI package now runs component tests under jsdom: the React Router plugin is dropped for test runs because its runtime preamble has no framework to come from, and JSX is transformed by esbuild instead. The tests assert what the feature is for rather than that it renders: an imports-only hunk is dimmed and says why, real work is left alone, whitespace in a file where indentation is syntax is not dimmed, and a hunk the walkthrough points at is highlighted in preference to being dimmed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- package-lock.json | 686 ++++++++++++++++++ packages/ui/package.json | 2 + .../ui/src/components/diff/file-block.tsx | 14 +- .../src/components/diff/hunk-block-split.tsx | 2 +- .../ui/src/components/diff/hunk-block.tsx | 2 +- .../ui/tests/file-block-attention.test.tsx | 124 ++++ .../ui/tests/hunk-attention-render.test.tsx | 61 ++ packages/ui/vite.config.ts | 9 +- 8 files changed, 890 insertions(+), 10 deletions(-) create mode 100644 packages/ui/tests/file-block-attention.test.tsx create mode 100644 packages/ui/tests/hunk-attention-render.test.tsx diff --git a/package-lock.json b/package-lock.json index 833eb96..af28989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,59 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -450,6 +503,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -504,6 +567,19 @@ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", @@ -543,6 +619,146 @@ "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "license": "Apache-2.0" }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@diffity/git": { "resolved": "packages/git", "link": true @@ -1006,6 +1222,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -2483,6 +2717,55 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2493,6 +2776,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3051,6 +3342,17 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3104,6 +3406,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3450,6 +3762,20 @@ "layout-base": "^1.0.0" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3964,6 +4290,35 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -3987,6 +4342,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4099,6 +4461,14 @@ "resolved": "packages/cli", "link": true }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", @@ -4575,6 +4945,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4743,6 +5126,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4797,6 +5187,83 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -5168,6 +5635,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5469,6 +5947,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mermaid": { "version": "11.13.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", @@ -6361,6 +6846,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -6371,6 +6886,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -6402,6 +6927,14 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -6604,6 +7137,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -6750,6 +7293,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6970,6 +7526,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -7041,6 +7604,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -7127,6 +7736,16 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -7635,6 +8254,19 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -7645,6 +8277,41 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -7696,6 +8363,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7831,9 +8515,11 @@ "devDependencies": { "@react-router/dev": "^7.13.2", "@react-router/fs-routes": "^7.13.2", + "@testing-library/react": "^16.3.2", "@types/nprogress": "^0.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "jsdom": "^30.0.1", "typescript": "^6.0.2", "vite": "^8.0.2", "vitest": "^4.1.0" diff --git a/packages/ui/package.json b/packages/ui/package.json index 30ed468..02f5baa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -36,9 +36,11 @@ "devDependencies": { "@react-router/dev": "^7.13.2", "@react-router/fs-routes": "^7.13.2", + "@testing-library/react": "^16.3.2", "@types/nprogress": "^0.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "jsdom": "^30.0.1", "typescript": "^6.0.2", "vite": "^8.0.2", "vitest": "^4.1.0" diff --git a/packages/ui/src/components/diff/file-block.tsx b/packages/ui/src/components/diff/file-block.tsx index f20c65d..7abcabf 100644 --- a/packages/ui/src/components/diff/file-block.tsx +++ b/packages/ui/src/components/diff/file-block.tsx @@ -260,21 +260,21 @@ export function FileBlock(props: FileBlockProps) { requestAnimationFrame(processChunk); - const hunkAttention = useMemo( + return () => { + cancelled = true; + }; + }, [file, highlightLine]); + + const hunkAttention = useMemo( () => file.hunks.map(hunk => classifyHunk(file, hunk)), [file], ); const isHunkFocused = useCallback( - (hunk: typeof file.hunks[number]) => hunkIntersectsRanges(hunk, focusRanges), + (hunk: DiffHunk) => hunkIntersectsRanges(hunk, focusRanges), [focusRanges], ); - return () => { - cancelled = true; - }; - }, [file, highlightLine]); - const gaps = useMemo(() => { if (isNewFile) { return []; diff --git a/packages/ui/src/components/diff/hunk-block-split.tsx b/packages/ui/src/components/diff/hunk-block-split.tsx index 672564a..25cf2d4 100644 --- a/packages/ui/src/components/diff/hunk-block-split.tsx +++ b/packages/ui/src/components/diff/hunk-block-split.tsx @@ -279,7 +279,7 @@ export function renderSplitRows( export function HunkBlockSplit(props: HunkBlockSplitProps) { const { - hunk, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap, + hunk, attentionClass = '', attentionTitle, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap, threads, pendingSelection, currentAuthor, isLineSelected, onLineMouseDown, onLineMouseEnter, onCommentClick, onAddThread, onReply, onResolve, onUnresolve, onEditComment, onDeleteComment, onDeleteThread, diff --git a/packages/ui/src/components/diff/hunk-block.tsx b/packages/ui/src/components/diff/hunk-block.tsx index 8471bbc..9f2a7da 100644 --- a/packages/ui/src/components/diff/hunk-block.tsx +++ b/packages/ui/src/components/diff/hunk-block.tsx @@ -111,7 +111,7 @@ export function renderLineWithComments( export function HunkBlock(props: HunkBlockProps) { const { - hunk, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap, + hunk, attentionClass = '', attentionTitle, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap, threads, pendingSelection, currentAuthor, isLineSelected, onLineMouseDown, onLineMouseEnter, onCommentClick, onAddThread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread, diff --git a/packages/ui/tests/file-block-attention.test.tsx b/packages/ui/tests/file-block-attention.test.tsx new file mode 100644 index 0000000..19076f9 --- /dev/null +++ b/packages/ui/tests/file-block-attention.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { DiffFile, DiffHunk, DiffLine } from '@diffity/parser'; +import { FileBlock } from '../src/components/diff/file-block'; +import type { CommentActions } from '../src/hooks/use-comment-actions'; + +function line(type: DiffLine['type'], content: string, num: number): DiffLine { + return { + type, + content, + oldLineNumber: type === 'add' ? null : num, + newLineNumber: type === 'delete' ? null : num, + }; +} + +function hunkAt(newStart: number, lines: DiffLine[]): DiffHunk { + return { + header: `@@ -${newStart},${lines.length} +${newStart},${lines.length} @@`, + oldStart: newStart, + oldCount: lines.length, + newStart, + newCount: lines.length, + lines, + }; +} + +function file(path: string, hunks: DiffHunk[]): DiffFile { + return { + oldPath: path, + newPath: path, + status: 'modified', + hunks, + additions: 1, + deletions: 0, + isBinary: false, + }; +} + +// The component takes a bag of callbacks it never invokes during a plain render. +const commentActions = {} as CommentActions; + +function renderFileBlock(target: DiffFile, focusRanges?: { startLine: number; endLine: number }[]) { + // FileBlock expands context through a query, so it needs a client even when nothing fetches. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return render( + + + , + ); +} + +function tbodyClasses(): string[] { + return Array.from(document.querySelectorAll('tbody')).map(body => body.className); +} + +afterEach(cleanup); + +describe('FileBlock attention', () => { + const importsHunk = hunkAt(1, [line('add', "import { a } from 'a';", 1)]); + const workHunk = hunkAt(40, [line('add', 'doWork();', 40)]); + + it('renders at all', () => { + expect(() => renderFileBlock(file('src/a.ts', [workHunk]))).not.toThrow(); + expect(document.querySelectorAll('tbody').length).toBeGreaterThan(0); + }); + + it('dims an imports-only hunk and says why', () => { + renderFileBlock(file('src/a.ts', [importsHunk])); + + expect(tbodyClasses().some(c => c.includes('opacity-45'))).toBe(true); + const titled = Array.from(document.querySelectorAll('tbody[title]')); + expect(titled.length).toBeGreaterThan(0); + expect(titled[0].getAttribute('title')).toBe('Dimmed: imports only'); + }); + + it('leaves real work at full attention', () => { + renderFileBlock(file('src/a.ts', [workHunk])); + + expect(tbodyClasses().some(c => c.includes('opacity-45'))).toBe(false); + expect(document.querySelectorAll('tbody[title]').length).toBe(0); + }); + + it('does not dim whitespace in a file where indentation is syntax', () => { + const reindent = hunkAt(1, [line('delete', ' key: value', 1), line('add', ' key: value', 1)]); + renderFileBlock(file('.github/workflows/ci.yml', [reindent])); + + expect(tbodyClasses().some(c => c.includes('opacity-45'))).toBe(false); + }); + + it('highlights a hunk the walkthrough points at, in preference to dimming it', () => { + renderFileBlock(file('src/a.ts', [importsHunk]), [{ startLine: 1, endLine: 1 }]); + + expect(tbodyClasses().some(c => c.includes('bg-accent/5'))).toBe(true); + expect(tbodyClasses().some(c => c.includes('opacity-45'))).toBe(false); + expect(document.querySelector('tbody[title]')?.getAttribute('title')).toBe( + 'The walkthrough points here', + ); + }); + + it('highlights only the hunk in range', () => { + renderFileBlock(file('src/a.ts', [importsHunk, workHunk]), [{ startLine: 40, endLine: 40 }]); + + const focused = tbodyClasses().filter(c => c.includes('bg-accent/5')); + const dimmed = tbodyClasses().filter(c => c.includes('opacity-45')); + expect(focused.length).toBeGreaterThan(0); + expect(dimmed.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/ui/tests/hunk-attention-render.test.tsx b/packages/ui/tests/hunk-attention-render.test.tsx new file mode 100644 index 0000000..3ef8dcf --- /dev/null +++ b/packages/ui/tests/hunk-attention-render.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import type { DiffHunk, DiffLine } from '@diffity/parser'; +import { HunkBlock } from '../src/components/diff/hunk-block'; +import { HunkBlockSplit } from '../src/components/diff/hunk-block-split'; + +function line(type: DiffLine['type'], content: string, num: number): DiffLine { + return { + type, + content, + oldLineNumber: type === 'add' ? null : num, + newLineNumber: type === 'delete' ? null : num, + }; +} + +const hunk: DiffHunk = { + header: '@@ -1,2 +1,2 @@', + oldStart: 1, + oldCount: 2, + newStart: 1, + newCount: 2, + lines: [line('context', 'const a = 1;', 1), line('add', 'const b = 2;', 2)], +}; + +// A tbody cannot live in a div, so the component gets a real table to render into. +function renderInTable(element: React.ReactElement) { + const table = document.createElement('table'); + document.body.appendChild(table); + return render(element, { container: table }); +} + +afterEach(cleanup); + +describe.each([ + ['unified', HunkBlock], + ['split', HunkBlockSplit], +])('%s hunk renderer', (_name, Component) => { + it('renders without throwing when no attention is passed', () => { + expect(() => renderInTable()).not.toThrow(); + }); + + it('puts the attention class on every row group of the hunk', () => { + renderInTable(); + + const bodies = Array.from(document.querySelectorAll('tbody')); + expect(bodies.length).toBeGreaterThan(0); + for (const body of bodies) { + expect(body.className).toContain('opacity-45'); + expect(body.getAttribute('title')).toBe('Dimmed: imports only'); + } + }); + + it('leaves the row groups unmarked when the hunk needs no attention', () => { + renderInTable(); + + for (const body of Array.from(document.querySelectorAll('tbody'))) { + expect(body.className).not.toContain('opacity-45'); + expect(body.getAttribute('title')).toBeNull(); + } + }); +}); diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 11c48a7..d67866f 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -2,10 +2,17 @@ import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; +// The React Router plugin injects a runtime preamble that a component test has no framework to +// provide, so tests build with plain esbuild JSX instead. +const isTest = !!process.env.VITEST; + export default defineConfig({ - plugins: [tailwindcss(), reactRouter()], + plugins: isTest ? [tailwindcss()] : [tailwindcss(), reactRouter()], + esbuild: { jsx: "automatic", jsxImportSource: "react" }, test: { include: ["tests/**/*.test.{ts,tsx}"], + // Component tests render for real: a render-time ReferenceError has nothing else catching it. + environment: "jsdom", }, server: { proxy: {