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"> </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"> </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>