DiffView.svelte

  1<script lang="ts">
  2	import type { DiffFile, DiffLine, DiffHunk } from '$lib/types/index';
  3	import type { ReviewThread } from '$lib/types/index';
  4	import { viewMode, commentingLine } from '$lib/stores/ui';
  5	import { threadsByFile } from '$lib/stores/review';
  6	import { resetHunk, diffFiles, vcs } from '$lib/stores/files';
  7	import CommentEditor from './CommentEditor.svelte';
  8	import CommentThread from './CommentThread.svelte';
  9
 10	interface Props {
 11		file: DiffFile;
 12	}
 13
 14	let { file }: Props = $props();
 15	let loadingFull = $state(false);
 16
 17	async function loadFullFile() {
 18		loadingFull = true;
 19		try {
 20			const res = await fetch(`/api/diff?file=${encodeURIComponent(file.path)}&full=1`);
 21			const data = await res.json();
 22			if (data.files?.[0]) {
 23				// Replace this file in the diffFiles store
 24				diffFiles.update((files) =>
 25					files.map((f) => f.path === file.path ? data.files[0] : f)
 26				);
 27			}
 28		} finally {
 29			loadingFull = false;
 30		}
 31	}
 32
 33	// Track expanded context lines per hunk boundary
 34	let expandedLines = $state<Map<string, DiffLine[]>>(new Map());
 35
 36	let fileThreads = $derived($threadsByFile.get(file.path) || []);
 37
 38	function getThreadsForLine(lineNumber: number, side: 'old' | 'new'): ReviewThread[] {
 39		return fileThreads.filter((t) => t.lineNumber === lineNumber && t.side === side);
 40	}
 41
 42	function startComment(lineNumber: number, side: 'old' | 'new', originalLineContent?: string) {
 43		$commentingLine = { filePath: file.path, lineNumber, side, originalLineContent };
 44	}
 45
 46	function isCommenting(lineNumber: number, side: 'old' | 'new'): boolean {
 47		const c = $commentingLine;
 48		return c !== null && c.filePath === file.path && c.lineNumber === lineNumber && c.side === side;
 49	}
 50
 51	function getLineKey(line: DiffLine): number {
 52		if (line.type === 'del') return line.oldNumber!;
 53		return line.newNumber!;
 54	}
 55
 56	function getLineSide(line: DiffLine): 'old' | 'new' {
 57		return line.type === 'del' ? 'old' : 'new';
 58	}
 59
 60	function lineBg(type: string): string {
 61		if (type === 'add') return 'bg-diff-add';
 62		if (type === 'del') return 'bg-diff-del';
 63		return '';
 64	}
 65
 66	const EXPAND_COUNT = 20;
 67
 68	// Get the effective end line of a hunk (including its after-expansion)
 69	function getHunkEndLine(hunkIndex: number): number {
 70		const hunk = file.hunks[hunkIndex];
 71		const afterLines = expandedLines.get(`after-${hunkIndex}`) || [];
 72		if (afterLines.length > 0) {
 73			return afterLines[afterLines.length - 1].newNumber || afterLines[afterLines.length - 1].oldNumber || 0;
 74		}
 75		return hunk.newStart + hunk.newLines - 1;
 76	}
 77
 78	// Get the effective start line of a hunk (including its before-expansion)
 79	function getHunkStartLine(hunkIndex: number): number {
 80		const hunk = file.hunks[hunkIndex];
 81		const beforeLines = expandedLines.get(`before-${hunkIndex}`) || [];
 82		if (beforeLines.length > 0) {
 83			return beforeLines[0].newNumber || beforeLines[0].oldNumber || 0;
 84		}
 85		return hunk.newStart;
 86	}
 87
 88	async function expandBefore(hunkIndex: number) {
 89		const key = `before-${hunkIndex}`;
 90		const existing = expandedLines.get(key) || [];
 91		const currentFirstLine = getHunkStartLine(hunkIndex);
 92
 93		// Don't expand past the previous hunk's end
 94		let minLine = 1;
 95		if (hunkIndex > 0) {
 96			minLine = getHunkEndLine(hunkIndex - 1) + 1;
 97		}
 98
 99		const endLine = currentFirstLine - 1;
100		const startLine = Math.max(minLine, endLine - EXPAND_COUNT + 1);
101		if (startLine > endLine) return;
102
103		const res = await fetch(`/api/context?file=${encodeURIComponent(file.path)}&start=${startLine}&end=${endLine}`);
104		const data = await res.json();
105		if (data.lines) {
106			const newMap = new Map(expandedLines);
107			newMap.set(key, [...data.lines, ...existing]);
108			expandedLines = newMap;
109		}
110	}
111
112	async function expandAfter(hunkIndex: number) {
113		const key = `after-${hunkIndex}`;
114		const existing = expandedLines.get(key) || [];
115		const currentLastLine = getHunkEndLine(hunkIndex);
116
117		// Don't expand past the next hunk's start
118		let maxLine = Infinity;
119		if (hunkIndex < file.hunks.length - 1) {
120			maxLine = getHunkStartLine(hunkIndex + 1) - 1;
121		}
122
123		const startLine = currentLastLine + 1;
124		const endLine = Math.min(startLine + EXPAND_COUNT - 1, maxLine);
125		if (startLine > endLine) return;
126
127		const res = await fetch(`/api/context?file=${encodeURIComponent(file.path)}&start=${startLine}&end=${endLine}`);
128		const data = await res.json();
129		if (data.lines && data.lines.length > 0) {
130			const newMap = new Map(expandedLines);
131			newMap.set(key, [...existing, ...data.lines]);
132			expandedLines = newMap;
133		}
134	}
135
136	function getExpandedBefore(hunkIndex: number): DiffLine[] {
137		return expandedLines.get(`before-${hunkIndex}`) || [];
138	}
139
140	function getExpandedAfter(hunkIndex: number): DiffLine[] {
141		return expandedLines.get(`after-${hunkIndex}`) || [];
142	}
143
144	function canExpandBefore(hunkIndex: number): boolean {
145		const currentStart = getHunkStartLine(hunkIndex);
146		let minLine = 1;
147		if (hunkIndex > 0) {
148			minLine = getHunkEndLine(hunkIndex - 1) + 1;
149		}
150		return currentStart > minLine;
151	}
152
153	function canExpandAfter(hunkIndex: number): boolean {
154		const currentEnd = getHunkEndLine(hunkIndex);
155		if (hunkIndex < file.hunks.length - 1) {
156			const nextStart = getHunkStartLine(hunkIndex + 1);
157			return currentEnd < nextStart - 1;
158		}
159		return true; // last hunk — can always try
160	}
161
162	// Hide the hunk header when the previous hunk's expanded area
163	// reaches right up to this hunk (no gap left)
164	function shouldShowHeader(hunkIndex: number): boolean {
165		if (hunkIndex === 0) return true; // always show first header
166		return canExpandBefore(hunkIndex);
167	}
168</script>
169
170<div class="diff-view font-mono text-sm">
171	{#each file.hunks as hunk, hunkIndex}
172		{@const beforeLines = getExpandedBefore(hunkIndex)}
173		{@const afterLines = getExpandedAfter(hunkIndex)}
174
175		<!-- Hunk header — hidden when merged with previous hunk -->
176		{#if shouldShowHeader(hunkIndex)}
177			<div class="bg-panel text-muted text-xs border-y border-border select-none flex items-center">
178				{#if canExpandBefore(hunkIndex)}
179					<button
180						class="text-accent-blue hover:opacity-75 hover:bg-hover transition-colors px-3 py-1"
181						onclick={() => expandBefore(hunkIndex)}
182						title="Show {EXPAND_COUNT} more lines above"
183					>
184						<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
185							<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
186						</svg>
187					</button>
188				{/if}
189				<span class="flex-1 px-2 py-1">{hunk.header}</span>
190				{#if $vcs !== 'jj'}
191					<button
192						class="text-accent-red/60 hover:text-accent-red hover:bg-hover transition-colors px-2 py-1 text-xs"
193						onclick={() => resetHunk(file.path, hunk.header)}
194						title="Reset this change block"
195					>
196						↩ Reset
197					</button>
198				{/if}
199			</div>
200		{/if}
201
202		{#if $viewMode === 'unified'}
203			<!-- Expanded lines before hunk -->
204			{#each beforeLines as line}
205				{@const lineNum = line.newNumber || line.oldNumber || 0}
206				<div class="diff-line group flex hover:bg-hover/20 relative">
207					<button class="absolute left-0 top-0 w-5 h-full flex items-center justify-center opacity-0 group-hover:opacity-100 text-accent-blue z-10 transition-opacity" onclick={() => startComment(lineNum, 'new', line.content)} title="Add comment">+</button>
208					<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{line.oldNumber ?? ''}</span>
209					<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 border-r border-border">{line.newNumber ?? ''}</span>
210					<span class="w-6 shrink-0 text-center select-none leading-6"> </span>
211					<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-4">{#if line.html}{@html line.html}{:else}{line.content || ' '}{/if}</span>
212				</div>
213			{/each}
214
215			<!-- Actual hunk lines -->
216			{#each hunk.lines as line}
217				{@const lineNum = getLineKey(line)}
218				{@const side = getLineSide(line)}
219				{@const lineThreads = getThreadsForLine(lineNum, side)}
220				<div class="diff-line group flex hover:bg-hover/20 relative {lineBg(line.type)}">
221					<button class="absolute left-0 top-0 w-5 h-full flex items-center justify-center opacity-0 group-hover:opacity-100 text-accent-blue z-10 transition-opacity" onclick={() => startComment(lineNum, side, line.content)} title="Add comment">+</button>
222					<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{line.oldNumber ?? ''}</span>
223					<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 border-r border-border">{line.newNumber ?? ''}</span>
224					<span class="w-6 shrink-0 text-center select-none leading-6 {line.type === 'add' ? 'text-accent-green' : line.type === 'del' ? 'text-accent-red' : ''}">{line.type === 'add' ? '+' : line.type === 'del' ? '-' : ' '}</span>
225					<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-4">{#if line.html}{@html line.html}{:else}{line.content || ' '}{/if}</span>
226				</div>
227				{#each lineThreads as thread}<CommentThread {thread} />{/each}
228				{#if isCommenting(lineNum, side)}<CommentEditor filePath={file.path} lineNumber={lineNum} {side} originalLineContent={$commentingLine?.originalLineContent} />{/if}
229			{/each}
230
231			<!-- Expanded lines after hunk -->
232			{#each afterLines as line}
233				{@const lineNum = line.newNumber || line.oldNumber || 0}
234				<div class="diff-line group flex hover:bg-hover/20 relative">
235					<button class="absolute left-0 top-0 w-5 h-full flex items-center justify-center opacity-0 group-hover:opacity-100 text-accent-blue z-10 transition-opacity" onclick={() => startComment(lineNum, 'new', line.content)} title="Add comment">+</button>
236					<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{line.oldNumber ?? ''}</span>
237					<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 border-r border-border">{line.newNumber ?? ''}</span>
238					<span class="w-6 shrink-0 text-center select-none leading-6"> </span>
239					<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-4">{#if line.html}{@html line.html}{:else}{line.content || ' '}{/if}</span>
240				</div>
241			{/each}
242
243			<!-- Expand down button below hunk content -->
244			{#if canExpandAfter(hunkIndex)}
245				<div class="flex items-center border-b border-border/50 bg-panel/30 hover:bg-panel/60 transition-colors">
246					<button
247						class="text-accent-blue hover:opacity-75 transition-colors px-3 py-0.5 flex items-center gap-2 text-xs"
248						onclick={() => expandAfter(hunkIndex)}
249						title="Show {EXPAND_COUNT} more lines below"
250					>
251						<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
252							<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
253						</svg>
254						Expand
255					</button>
256				</div>
257			{/if}
258		{:else}
259			<!-- SPLIT VIEW -->
260			<!-- Expanded before -->
261			{#each beforeLines as line}
262				<div class="flex">
263					<div class="w-1/2 border-r border-border">
264						<div class="diff-line flex leading-6">
265							<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{line.oldNumber ?? ''}</span>
266							<span class="w-6 shrink-0 text-center select-none leading-6"> </span>
267							<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-2">{#if line.html}{@html line.html}{:else}{line.content || ' '}{/if}</span>
268						</div>
269					</div>
270					<div class="w-1/2">
271						<div class="diff-line flex leading-6">
272							<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{line.newNumber ?? ''}</span>
273							<span class="w-6 shrink-0 text-center select-none leading-6"> </span>
274							<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-2">{#if line.html}{@html line.html}{:else}{line.content || ' '}{/if}</span>
275						</div>
276					</div>
277				</div>
278			{/each}
279
280			<!-- Split hunk lines -->
281			{@const pairs = buildSplitPairs(hunk.lines)}
282			{#each pairs as pair}
283				{@const oldThreads = pair.old ? getThreadsForLine(pair.old.oldNumber!, 'old') : []}
284				{@const newThreads = pair.new ? getThreadsForLine(pair.new.newNumber!, 'new') : []}
285				<div class="flex">
286					<div class="w-1/2 border-r border-border">
287						{#if pair.old}
288							<div class="diff-line group flex hover:bg-hover/20 relative {pair.old.type === 'del' ? 'bg-diff-del' : ''}">
289								<button class="absolute left-0 top-0 w-5 h-full flex items-center justify-center opacity-0 group-hover:opacity-100 text-accent-blue z-10 transition-opacity" onclick={() => startComment(pair.old!.oldNumber!, 'old', pair.old!.content)} title="Add comment">+</button>
290								<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{pair.old.oldNumber ?? ''}</span>
291								<span class="w-6 shrink-0 text-center select-none leading-6 {pair.old.type === 'del' ? 'text-accent-red' : ''}">{pair.old.type === 'del' ? '-' : ' '}</span>
292								<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-2">{#if pair.old.html}{@html pair.old.html}{:else}{pair.old.content || ' '}{/if}</span>
293							</div>
294						{:else}<div class="diff-line flex bg-panel/20 leading-6">&nbsp;</div>{/if}
295					</div>
296					<div class="w-1/2">
297						{#if pair.new}
298							<div class="diff-line group flex hover:bg-hover/20 relative {pair.new.type === 'add' ? 'bg-diff-add' : ''}">
299								<button class="absolute left-0 top-0 w-5 h-full flex items-center justify-center opacity-0 group-hover:opacity-100 text-accent-blue z-10 transition-opacity" onclick={() => startComment(pair.new!.newNumber!, 'new', pair.new!.content)} title="Add comment">+</button>
300								<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{pair.new.newNumber ?? ''}</span>
301								<span class="w-6 shrink-0 text-center select-none leading-6 {pair.new.type === 'add' ? 'text-accent-green' : ''}">{pair.new.type === 'add' ? '+' : ' '}</span>
302								<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-2">{#if pair.new.html}{@html pair.new.html}{:else}{pair.new.content || ' '}{/if}</span>
303							</div>
304						{:else}<div class="diff-line flex bg-panel/20 leading-6">&nbsp;</div>{/if}
305					</div>
306				</div>
307				{#if oldThreads.length > 0 || newThreads.length > 0}
308					<div class="flex">
309						<div class="w-1/2 border-r border-border">
310							{#each oldThreads as thread}<CommentThread {thread} />{/each}
311							{#if pair.old && isCommenting(pair.old.oldNumber!, 'old')}<CommentEditor filePath={file.path} lineNumber={pair.old.oldNumber!} side="old" originalLineContent={$commentingLine?.originalLineContent} />{/if}
312						</div>
313						<div class="w-1/2">
314							{#each newThreads as thread}<CommentThread {thread} />{/each}
315							{#if pair.new && isCommenting(pair.new.newNumber!, 'new')}<CommentEditor filePath={file.path} lineNumber={pair.new.newNumber!} side="new" originalLineContent={$commentingLine?.originalLineContent} />{/if}
316						</div>
317					</div>
318				{/if}
319			{/each}
320
321			<!-- Expanded after -->
322			{#each afterLines as line}
323				<div class="flex">
324					<div class="w-1/2 border-r border-border">
325						<div class="diff-line flex leading-6">
326							<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{line.oldNumber ?? ''}</span>
327							<span class="w-6 shrink-0 text-center select-none leading-6"> </span>
328							<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-2">{#if line.html}{@html line.html}{:else}{line.content || ' '}{/if}</span>
329						</div>
330					</div>
331					<div class="w-1/2">
332						<div class="diff-line flex leading-6">
333							<span class="w-12 shrink-0 text-right pr-2 text-faint select-none text-xs leading-6 pl-5">{line.newNumber ?? ''}</span>
334							<span class="w-6 shrink-0 text-center select-none leading-6"> </span>
335							<span class="flex-1 whitespace-pre-wrap break-all leading-6 pr-2">{#if line.html}{@html line.html}{:else}{line.content || ' '}{/if}</span>
336						</div>
337					</div>
338				</div>
339			{/each}
340
341			{#if canExpandAfter(hunkIndex)}
342				<div class="flex items-center border-b border-border/50 bg-panel/30 hover:bg-panel/60 transition-colors">
343					<button
344						class="text-accent-blue hover:opacity-75 transition-colors px-3 py-0.5 flex items-center gap-2 text-xs"
345						onclick={() => expandAfter(hunkIndex)}
346						title="Show {EXPAND_COUNT} more lines below"
347					>
348						<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
349							<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
350						</svg>
351						Expand
352					</button>
353				</div>
354			{/if}
355		{/if}
356	{/each}
357
358	{#if file.truncated}
359		<div class="bg-panel border-t border-border px-4 py-3 text-center">
360			<p class="text-sm text-muted mb-2">
361				Large file: showing {file.hunks.reduce((s, h) => s + h.lines.length, 0).toLocaleString()} of {(file.totalLines || 0).toLocaleString()} changed lines
362			</p>
363			<button
364				class="text-sm px-4 py-1.5 rounded bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30 transition-colors disabled:opacity-50"
365				onclick={loadFullFile}
366				disabled={loadingFull}
367			>
368				{loadingFull ? '⏳ Loading...' : 'Load full file (may be slow)'}
369			</button>
370		</div>
371	{/if}
372</div>
373
374<script lang="ts" module>
375	import type { DiffLine as DL } from '$lib/types/index';
376
377	interface SplitPair {
378		old: DL | null;
379		new: DL | null;
380	}
381
382	function buildSplitPairs(lines: DL[]): SplitPair[] {
383		const pairs: SplitPair[] = [];
384		const dels: DL[] = [];
385		const adds: DL[] = [];
386
387		function flushPending() {
388			const max = Math.max(dels.length, adds.length);
389			for (let i = 0; i < max; i++) {
390				pairs.push({
391					old: dels[i] || null,
392					new: adds[i] || null
393				});
394			}
395			dels.length = 0;
396			adds.length = 0;
397		}
398
399		for (const line of lines) {
400			if (line.type === 'context') {
401				flushPending();
402				pairs.push({ old: line, new: line });
403			} else if (line.type === 'del') {
404				dels.push(line);
405			} else if (line.type === 'add') {
406				adds.push(line);
407			}
408		}
409		flushPending();
410		return pairs;
411	}
412</script>