fix: expander directions, keyboard shortcuts, and diff stats

This commit is contained in:
Kamran Ahmed
2026-03-14 02:27:15 +00:00
parent d124b5e15e
commit dacb292404
34 changed files with 384 additions and 93 deletions
+13 -27
View File
@@ -563,7 +563,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -581,7 +580,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -599,7 +597,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -617,7 +614,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -635,7 +631,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -653,7 +648,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -671,7 +665,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -689,7 +682,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -707,7 +699,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -725,7 +716,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -743,7 +733,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -761,7 +750,6 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -776,7 +764,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
},
@@ -797,7 +784,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -815,7 +801,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -2449,12 +2434,12 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2470,12 +2455,12 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2491,12 +2476,12 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2512,12 +2497,12 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2533,12 +2518,12 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2554,12 +2539,12 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2575,12 +2560,12 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2596,12 +2581,12 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2617,12 +2602,12 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2638,12 +2623,12 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2659,12 +2644,12 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -3637,7 +3622,8 @@
"@types/react-dom": "latest",
"@vitejs/plugin-react": "latest",
"typescript": "latest",
"vite": "latest"
"vite": "latest",
"vitest": "^4.1.0"
}
},
"packages/ui/node_modules/@tailwindcss/vite": {
+1 -1
View File
@@ -10,7 +10,7 @@
],
"scripts": {
"build": "npm run build -w @diffity/parser && npm run build -w @diffity/ui && npm run build -w diffity",
"test": "npm run test -w @diffity/parser",
"test": "npm run test -w @diffity/parser && npm run test -w @diffity/ui",
"dev": "npm run dev -w @diffity/ui",
"start": "npm run dev -w diffity"
},
@@ -2,11 +2,11 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDiff } from '../parse.js';
import { parseDiff } from '../src/parse.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fixture = (name: string) =>
readFileSync(resolve(__dirname, '../__fixtures__', name), 'utf-8');
readFileSync(resolve(__dirname, './fixtures', name), 'utf-8');
describe('parseDiff', () => {
describe('basic formats', () => {
@@ -2,11 +2,11 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDiff } from '../index.js';
import { parseDiff } from '../src/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fixture = (name: string) =>
readFileSync(resolve(__dirname, '../__fixtures__', name), 'utf-8');
readFileSync(resolve(__dirname, './fixtures', name), 'utf-8');
describe('word diff integration', () => {
it('attaches word diff to paired delete+add lines', () => {
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { computeWordDiff } from '../word-diff.js';
import { computeWordDiff } from '../src/word-diff.js';
describe('computeWordDiff', () => {
it('returns equal segment for identical lines', () => {
+5 -2
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
@@ -22,6 +24,7 @@
"@types/react-dom": "latest",
"@vitejs/plugin-react": "latest",
"typescript": "latest",
"vite": "latest"
"vite": "latest",
"vitest": "^4.1.0"
}
}
+6
View File
@@ -82,6 +82,12 @@ export function App() {
next.add(path);
return next;
});
} else {
setCollapsedFiles(prev => {
const next = new Set(prev);
next.delete(path);
return next;
});
}
}, []);
+38 -32
View File
@@ -173,17 +173,19 @@ export function FileBlock(props: FileBlockProps) {
linesFromBottom: [],
});
} else if (direction === 'down') {
next.set(gap.id, {
const newExpansion = {
...existing,
fromTop: existing.fromTop + (range.oldEnd - range.oldStart + 1),
linesFromTop: [...existing.linesFromTop, ...contextLines],
});
};
next.set(gap.id, newExpansion);
} else {
next.set(gap.id, {
const newExpansion = {
...existing,
fromBottom: existing.fromBottom + (range.oldEnd - range.oldStart + 1),
linesFromBottom: [...contextLines, ...existing.linesFromBottom],
});
};
next.set(gap.id, newExpansion);
}
return next;
@@ -241,7 +243,9 @@ export function FileBlock(props: FileBlockProps) {
}, [gapMap, getGapRemaining, loadingGap, handleExpand, expansions]);
const total = file.additions + file.deletions;
const addPct = total > 0 ? (file.additions / total) * 100 : 0;
const addBlocks = total > 0 ? Math.round((file.additions / total) * Math.min(5, total)) : 0;
const delBlocks = total > 0 ? Math.min(5, total) - addBlocks : 0;
const neutralBlocks = 5 - addBlocks - delBlocks;
const bottomGap = gapMap.get('bottom');
const bottomRemaining = bottomGap ? getGapRemaining(bottomGap).total : 0;
@@ -256,20 +260,6 @@ export function FileBlock(props: FileBlockProps) {
>
{collapsed ? '\u25b6' : '\u25bc'}
</IconButton>
<div className="flex gap-px shrink-0">
{Array.from({ length: Math.min(5, total) }).map((_, i) => (
<span
key={i}
className={cn(
'w-2 h-2 rounded-[1px]',
i < Math.round((addPct / 100) * Math.min(5, total))
? 'bg-added'
: 'bg-deleted'
)}
/>
))}
</div>
<DiffStats additions={file.additions} deletions={file.deletions} />
<span className="font-mono text-sm font-semibold truncate">
{showRename ? (
<>
@@ -283,7 +273,21 @@ export function FileBlock(props: FileBlockProps) {
</span>
{file.status !== 'modified' && <StatusBadge status={file.status} />}
{file.isBinary && <Badge className="bg-bg-tertiary text-text-muted">Binary</Badge>}
<div className="ml-auto flex items-center shrink-0">
<div className="ml-auto flex items-center gap-3 shrink-0">
<div className="flex items-center gap-1.5">
<DiffStats additions={file.additions} deletions={file.deletions} />
<div className="flex gap-px">
{Array.from({ length: addBlocks }).map((_, i) => (
<span key={`a${i}`} className="w-2 h-2 rounded-[1px] bg-added" />
))}
{Array.from({ length: delBlocks }).map((_, i) => (
<span key={`d${i}`} className="w-2 h-2 rounded-[1px] bg-deleted" />
))}
{Array.from({ length: neutralBlocks }).map((_, i) => (
<span key={`n${i}`} className="w-2 h-2 rounded-[1px] bg-border" />
))}
</div>
</div>
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer select-none hover:text-text transition-colors">
<input
type="checkbox"
@@ -391,26 +395,28 @@ function HunkWithGap(props: HunkWithGapProps) {
const HunkComponent = viewMode === 'split' ? HunkBlockSplit : HunkBlock;
const topExpansionRows = topExpansionLines && topExpansionLines.length > 0
? topExpansionLines.map((line) => (
<ContextRow key={`top-${line.oldLineNumber}`} line={line} viewMode={viewMode} highlightLine={highlightLine} />
))
: undefined;
const gapBottomRows = gapExpansion && gapExpansion.linesFromBottom.length > 0
? gapExpansion.linesFromBottom.map((line) => (
<ContextRow key={`${gapId}-bot-${line.oldLineNumber}`} line={line} viewMode={viewMode} highlightLine={highlightLine} />
))
: undefined;
return (
<>
{topExpansionLines && topExpansionLines.length > 0 && (
<tbody>
{topExpansionLines.map((line) => (
<ContextRow key={`top-${line.oldLineNumber}`} line={line} viewMode={viewMode} highlightLine={highlightLine} />
))}
</tbody>
)}
{gapExpansion && (
{gapExpansion && gapExpansion.linesFromTop.length > 0 && (
<tbody>
{gapExpansion.linesFromTop.map((line) => (
<ContextRow key={`${gapId}-top-${line.oldLineNumber}`} line={line} viewMode={viewMode} highlightLine={highlightLine} />
))}
{gapExpansion.linesFromBottom.map((line) => (
<ContextRow key={`${gapId}-bot-${line.oldLineNumber}`} line={line} viewMode={viewMode} highlightLine={highlightLine} />
))}
</tbody>
)}
<HunkComponent hunk={hunk} syntaxMap={syntaxMap} expandControls={expandControls} />
<HunkComponent hunk={hunk} syntaxMap={syntaxMap} expandControls={expandControls} topExpansionRows={topExpansionRows} bottomExpansionRows={gapBottomRows} />
</>
);
}
@@ -10,6 +10,8 @@ interface HunkBlockSplitProps {
hunk: DiffHunk;
syntaxMap?: Map<string, SyntaxToken[]>;
expandControls?: ExpandControls;
topExpansionRows?: React.ReactNode[];
bottomExpansionRows?: React.ReactNode[];
}
interface SplitRow {
@@ -129,12 +131,14 @@ function SplitCell(props: { line: DiffLineType | null; side: 'left' | 'right'; s
}
export function HunkBlockSplit(props: HunkBlockSplitProps) {
const { hunk, syntaxMap, expandControls } = props;
const { hunk, syntaxMap, expandControls, topExpansionRows, bottomExpansionRows } = props;
const rows = buildSplitRows(hunk.lines);
return (
<tbody className={expandControls?.wasExpanded && expandControls.remainingLines <= 0 ? '' : 'border-t border-border-muted'}>
<HunkHeader hunk={hunk} expandControls={expandControls} />
{topExpansionRows}
{bottomExpansionRows}
{rows.map((row, i) => (
<tr key={i} className="font-mono text-sm leading-5">
<SplitCell line={row.left} side="left" syntaxMap={syntaxMap} />
+5 -1
View File
@@ -6,14 +6,18 @@ interface HunkBlockProps {
hunk: DiffHunk;
syntaxMap?: Map<string, SyntaxToken[]>;
expandControls?: ExpandControls;
topExpansionRows?: React.ReactNode[];
bottomExpansionRows?: React.ReactNode[];
}
export function HunkBlock(props: HunkBlockProps) {
const { hunk, syntaxMap, expandControls } = props;
const { hunk, syntaxMap, expandControls, topExpansionRows, bottomExpansionRows } = props;
return (
<tbody className={expandControls?.wasExpanded && expandControls.remainingLines <= 0 ? '' : 'border-t border-border-muted'}>
<HunkHeader hunk={hunk} expandControls={expandControls} />
{topExpansionRows}
{bottomExpansionRows}
{hunk.lines.map((line, i) => {
const num = line.type === 'delete' ? line.oldLineNumber : line.newLineNumber;
const key = num !== null ? `${line.type}-${num}` : `line-${i}`;
+20 -24
View File
@@ -39,7 +39,7 @@ function Spinner() {
);
}
function ExpandDownIcon() {
function ArrowUpIcon() {
return (
<svg className="w-[14px] h-[14px]" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2.75a.75.75 0 01.75-.75h1a.75.75 0 010 1.5h-1A.75.75 0 012 2.75zm4 0a.75.75 0 01.75-.75h1a.75.75 0 010 1.5h-1A.75.75 0 016 2.75zm4 0a.75.75 0 01.75-.75h1a.75.75 0 010 1.5h-1a.75.75 0 01-.75-.75zM7.47 5.97a.75.75 0 011.06 0l3.25 3.25a.75.75 0 11-1.06 1.06L8.5 8.06V14.25a.75.75 0 01-1.5 0V8.06L4.78 10.28a.75.75 0 01-1.06-1.06l3.25-3.25z" />
@@ -47,7 +47,7 @@ function ExpandDownIcon() {
);
}
function ExpandUpIcon() {
function ArrowDownIcon() {
return (
<svg className="w-[14px] h-[14px]" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 13.25a.75.75 0 00.75.75h1a.75.75 0 000-1.5h-1a.75.75 0 00-.75.75zm4 0a.75.75 0 00.75.75h1a.75.75 0 000-1.5h-1a.75.75 0 00-.75.75zm4 0a.75.75 0 00.75.75h1a.75.75 0 000-1.5h-1a.75.75 0 00-.75.75zM7.47 10.03a.75.75 0 001.06 0l3.25-3.25a.75.75 0 00-1.06-1.06L8.5 7.94V1.75a.75.75 0 00-1.5 0v6.19L4.78 5.72a.75.75 0 00-1.06 1.06l3.25 3.25z" />
@@ -97,23 +97,18 @@ export function HunkHeader(props: HunkHeaderProps) {
if (position === 'top') {
return (
<>
<tr className={expandRow}>
{loading ? <Spinner /> : (
<td className={gutterCell}>
<button className={expandBtn} onClick={() => onExpand('up')} title={`Expand ${Math.min(remainingLines, 20)} lines`}>
<ExpandUpIcon />
</button>
</td>
)}
<td colSpan={3} />
</tr>
<tr className="bg-diff-hunk-bg">
<td colSpan={4} className="px-3 py-1 font-mono text-xs text-diff-hunk-text select-none">
{formatHunkHeader(hunk)}
<tr className="bg-diff-hunk-bg">
{loading ? <Spinner /> : (
<td className={gutterCell}>
<button className={expandBtn} onClick={() => onExpand('up')} title={`Expand ${Math.min(remainingLines, 20)} lines`}>
<ArrowUpIcon />
</button>
</td>
</tr>
</>
)}
<td colSpan={3} className="px-3 py-1 font-mono text-xs text-diff-hunk-text select-none">
{formatHunkHeader(hunk)}
</td>
</tr>
);
}
@@ -127,7 +122,7 @@ export function HunkHeader(props: HunkHeaderProps) {
onClick={() => onExpand(isSmallGap ? 'all' : showUp ? 'up' : 'down')}
title={isSmallGap ? `Expand all ${remainingLines} lines` : `Expand ${Math.min(remainingLines, 20)} lines`}
>
{isSmallGap ? <ChevronUpDownIcon /> : showUp ? <ExpandUpIcon /> : <ExpandDownIcon />}
{isSmallGap ? <ChevronUpDownIcon /> : showUp ? <ArrowUpIcon /> : <ArrowDownIcon />}
</button>
</td>
)}
@@ -143,8 +138,8 @@ export function HunkHeader(props: HunkHeaderProps) {
<tr className={expandRow}>
{loading ? <Spinner /> : (
<td className={gutterCell}>
<button className={expandBtn} onClick={() => onExpand('down')} title="Expand up">
<ExpandUpIcon />
<button className={expandBtn} onClick={() => onExpand('down')} title="Expand down">
<ArrowDownIcon />
</button>
</td>
)}
@@ -158,8 +153,8 @@ export function HunkHeader(props: HunkHeaderProps) {
</tr>
<tr className={expandRow}>
<td className={gutterCell}>
<button className={expandBtn} onClick={() => onExpand('up')} title="Expand down">
<ExpandDownIcon />
<button className={expandBtn} onClick={() => onExpand('up')} title="Expand up">
<ArrowUpIcon />
</button>
</td>
<td colSpan={3} />
@@ -184,7 +179,8 @@ export function ExpandRow(props: { position: 'top' | 'bottom'; remainingLines: n
onClick={() => onExpand('down')}
title={`Expand ${Math.min(remainingLines, 20)} lines`}
>
{position === 'bottom' ? <ExpandUpIcon /> : <ExpandDownIcon />}
{position === 'bottom' ? <ArrowDownIcon /> : <ArrowUpIcon />}
</button>
</td>
)}
+4
View File
@@ -30,6 +30,10 @@ export function useKeyboard(actions: KeyboardActions) {
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) {
return;
}
switch (e.key) {
case 'j':
e.preventDefault();
+279
View File
@@ -0,0 +1,279 @@
import { describe, it, expect } from 'vitest';
import type { DiffHunk } from '@diffity/parser';
import {
computeGaps,
createContextLines,
getExpandRange,
type ExpandableGap,
} from '../src/lib/context-expansion.js';
function makeHunk(oldStart: number, oldCount: number, newStart: number, newCount: number): DiffHunk {
return {
oldStart,
oldCount,
newStart,
newCount,
lines: [],
};
}
describe('computeGaps', () => {
it('returns empty for no hunks', () => {
expect(computeGaps([], null)).toEqual([]);
});
it('creates a top gap when first hunk does not start at line 1', () => {
const hunks = [makeHunk(10, 5, 10, 5)];
const gaps = computeGaps(hunks, null);
expect(gaps).toHaveLength(1);
expect(gaps[0]).toMatchObject({
id: 'top',
position: 'top',
oldStart: 1,
oldEnd: 9,
totalLines: 9,
});
});
it('does not create a top gap when first hunk starts at line 1', () => {
const hunks = [makeHunk(1, 5, 1, 5)];
const gaps = computeGaps(hunks, null);
expect(gaps).toHaveLength(0);
});
it('creates a between gap for non-adjacent hunks', () => {
const hunks = [makeHunk(1, 10, 1, 10), makeHunk(50, 5, 50, 5)];
const gaps = computeGaps(hunks, null);
expect(gaps).toHaveLength(1);
expect(gaps[0]).toMatchObject({
id: 'between-0',
position: 'between',
oldStart: 11,
oldEnd: 49,
totalLines: 39,
});
});
it('does not create a between gap for adjacent hunks', () => {
const hunks = [makeHunk(1, 10, 1, 10), makeHunk(11, 5, 11, 5)];
const gaps = computeGaps(hunks, null);
expect(gaps).toHaveLength(0);
});
it('creates a bottom gap when file has lines after last hunk', () => {
const hunks = [makeHunk(1, 10, 1, 10)];
const gaps = computeGaps(hunks, 50);
expect(gaps).toHaveLength(1);
expect(gaps[0]).toMatchObject({
id: 'bottom',
position: 'bottom',
oldStart: 11,
oldEnd: 50,
totalLines: 40,
});
});
it('does not create a bottom gap when fileLineCount is null', () => {
const hunks = [makeHunk(1, 10, 1, 10)];
const gaps = computeGaps(hunks, null);
expect(gaps).toHaveLength(0);
});
it('creates top, between, and bottom gaps together', () => {
const hunks = [makeHunk(20, 5, 20, 5), makeHunk(50, 5, 50, 5)];
const gaps = computeGaps(hunks, 100);
expect(gaps).toHaveLength(3);
expect(gaps[0].position).toBe('top');
expect(gaps[1].position).toBe('between');
expect(gaps[2].position).toBe('bottom');
});
it('creates multiple between gaps for multiple non-adjacent hunks', () => {
const hunks = [
makeHunk(1, 5, 1, 5),
makeHunk(20, 5, 20, 5),
makeHunk(50, 5, 50, 5),
];
const gaps = computeGaps(hunks, null);
expect(gaps).toHaveLength(2);
expect(gaps[0].id).toBe('between-0');
expect(gaps[0].oldStart).toBe(6);
expect(gaps[0].oldEnd).toBe(19);
expect(gaps[1].id).toBe('between-1');
expect(gaps[1].oldStart).toBe(25);
expect(gaps[1].oldEnd).toBe(49);
});
});
describe('createContextLines', () => {
const fileLines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`);
it('creates context lines for a given range', () => {
const lines = createContextLines(fileLines, 5, 8, 0);
expect(lines).toHaveLength(4);
expect(lines[0]).toMatchObject({ type: 'context', content: 'line 5', oldLineNumber: 5, newLineNumber: 5 });
expect(lines[3]).toMatchObject({ type: 'context', content: 'line 8', oldLineNumber: 8, newLineNumber: 8 });
});
it('applies new line number offset', () => {
const lines = createContextLines(fileLines, 5, 7, 3);
expect(lines[0]).toMatchObject({ oldLineNumber: 5, newLineNumber: 8 });
expect(lines[2]).toMatchObject({ oldLineNumber: 7, newLineNumber: 10 });
});
it('handles single line range', () => {
const lines = createContextLines(fileLines, 10, 10, 0);
expect(lines).toHaveLength(1);
expect(lines[0].content).toBe('line 10');
});
it('returns empty string for out-of-bounds lines', () => {
const lines = createContextLines(['a', 'b'], 3, 3, 0);
expect(lines).toHaveLength(1);
expect(lines[0].content).toBe('');
});
});
describe('getExpandRange', () => {
const gap: ExpandableGap = {
id: 'between-0',
position: 'between',
oldStart: 20,
oldEnd: 100,
newStart: 20,
newEnd: 100,
totalLines: 81,
};
const noExpansion = { fromTop: 0, fromBottom: 0 };
describe('direction: all', () => {
it('returns the full remaining range', () => {
const range = getExpandRange(gap, 'all', noExpansion);
expect(range).toEqual({ oldStart: 20, oldEnd: 100 });
});
it('returns the remaining range after partial expansion', () => {
const range = getExpandRange(gap, 'all', { fromTop: 10, fromBottom: 5 });
expect(range).toEqual({ oldStart: 30, oldEnd: 95 });
});
});
describe('direction: down (expand from top of gap)', () => {
it('returns a chunk from the start of the gap', () => {
const range = getExpandRange(gap, 'down', noExpansion);
expect(range).toEqual({ oldStart: 20, oldEnd: 39 });
});
it('continues from where previous expansion left off', () => {
const range = getExpandRange(gap, 'down', { fromTop: 20, fromBottom: 0 });
expect(range).toEqual({ oldStart: 40, oldEnd: 59 });
});
it('clamps to remaining end', () => {
const range = getExpandRange(gap, 'down', { fromTop: 70, fromBottom: 0 });
expect(range).toEqual({ oldStart: 90, oldEnd: 100 });
});
});
describe('direction: up (expand from bottom of gap)', () => {
it('returns a chunk from the end of the gap', () => {
const range = getExpandRange(gap, 'up', noExpansion);
expect(range).toEqual({ oldStart: 81, oldEnd: 100 });
});
it('continues from where previous expansion left off', () => {
const range = getExpandRange(gap, 'up', { fromTop: 0, fromBottom: 20 });
expect(range).toEqual({ oldStart: 61, oldEnd: 80 });
});
it('clamps to remaining start', () => {
const range = getExpandRange(gap, 'up', { fromTop: 0, fromBottom: 70 });
expect(range).toEqual({ oldStart: 20, oldEnd: 30 });
});
});
describe('edge cases', () => {
it('returns null when fully expanded', () => {
const range = getExpandRange(gap, 'down', { fromTop: 81, fromBottom: 0 });
expect(range).toBeNull();
});
it('returns null when top and bottom overlap', () => {
const range = getExpandRange(gap, 'up', { fromTop: 50, fromBottom: 40 });
expect(range).toBeNull();
});
it('expands all when remaining fits in one chunk', () => {
const smallGap: ExpandableGap = { ...gap, oldStart: 20, oldEnd: 30, totalLines: 11 };
const rangeDown = getExpandRange(smallGap, 'down', noExpansion);
expect(rangeDown).toEqual({ oldStart: 20, oldEnd: 30 });
const rangeUp = getExpandRange(smallGap, 'up', noExpansion);
expect(rangeUp).toEqual({ oldStart: 20, oldEnd: 30 });
});
it('handles top gap expanding upward', () => {
const topGap: ExpandableGap = {
id: 'top',
position: 'top',
oldStart: 1,
oldEnd: 50,
newStart: 1,
newEnd: 50,
totalLines: 50,
};
const range = getExpandRange(topGap, 'up', noExpansion);
expect(range).toEqual({ oldStart: 31, oldEnd: 50 });
});
it('handles bottom gap expanding downward', () => {
const bottomGap: ExpandableGap = {
id: 'bottom',
position: 'bottom',
oldStart: 80,
oldEnd: 120,
newStart: 80,
newEnd: 120,
totalLines: 41,
};
const range = getExpandRange(bottomGap, 'down', noExpansion);
expect(range).toEqual({ oldStart: 80, oldEnd: 99 });
});
it('handles simultaneous expansion from both directions', () => {
const rangeDown = getExpandRange(gap, 'down', { fromTop: 20, fromBottom: 20 });
expect(rangeDown).toEqual({ oldStart: 40, oldEnd: 59 });
const rangeUp = getExpandRange(gap, 'up', { fromTop: 20, fromBottom: 20 });
expect(rangeUp).toEqual({ oldStart: 61, oldEnd: 80 });
});
});
});
+3
View File
@@ -4,6 +4,9 @@ import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss(), react()],
test: {
include: ['tests/**/*.test.ts'],
},
server: {
proxy: {
'/api': {