mirror of
https://github.com/nilbuild/diffity.git
synced 2026-09-19 07:26:16 +08:00
fix: expander directions, keyboard shortcuts, and diff stats
This commit is contained in:
Generated
+13
-27
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
@@ -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
-2
@@ -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
-1
@@ -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', () => {
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), react()],
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user