10 files changed,
+513,
-58
+8,
-0
1@@ -3,6 +3,7 @@
2 import type { ReviewThread } from '$lib/types/index.ts';
3 import { viewMode, commentingLine } from '$lib/stores/ui.ts';
4 import { threadsByFile } from '$lib/stores/review.ts';
5+ import { resetHunk } from '$lib/stores/files.ts';
6 import CommentEditor from './CommentEditor.svelte';
7 import CommentThread from './CommentThread.svelte';
8
9@@ -169,6 +170,13 @@
10 </button>
11 {/if}
12 <span class="flex-1 px-2 py-1">{hunk.header}</span>
13+ <button
14+ class="text-accent-red/60 hover:text-accent-red hover:bg-hover transition-colors px-2 py-1 text-xs"
15+ onclick={() => resetHunk(file.path, hunk.header)}
16+ title="Reset this change block"
17+ >
18+ ↩ Reset
19+ </button>
20 </div>
21 {/if}
22
+71,
-11
1@@ -4,13 +4,40 @@
2
3 let exporting = $state(false);
4 let result = $state<{ success: boolean; path?: string; message?: string; text?: string } | null>(null);
5+ let clipboardState = $state<'idle' | 'loading' | 'ready' | 'copied'>('idle');
6+ let pendingClipboardText = $state<string | null>(null);
7
8 async function exportAs(format: string) {
9+ if (format === 'clipboard') {
10+ clipboardState = 'loading';
11+ pendingClipboardText = null;
12+ try {
13+ const res = await fetch('/api/export', {
14+ method: 'POST',
15+ headers: { 'Content-Type': 'application/json' },
16+ body: JSON.stringify({ format })
17+ });
18+ const data = await res.json();
19+ if (data?.text) {
20+ pendingClipboardText = data.text;
21+ // Ensure "Preparing..." is visible long enough for user to notice
22+ await new Promise((r) => setTimeout(r, 800));
23+ clipboardState = 'ready';
24+ } else {
25+ result = { success: false, message: 'No content to copy.' };
26+ clipboardState = 'idle';
27+ }
28+ } catch (err) {
29+ result = { success: false, message: String(err) };
30+ clipboardState = 'idle';
31+ }
32+ return;
33+ }
34+
35 exporting = true;
36 result = null;
37 try {
38 if (format === 'claude') {
39- // Use terminal panel with streaming
40 const res = await sendReviewToClaude();
41 if (res.success) {
42 $showExportDialog = false;
43@@ -26,11 +53,6 @@
44 body: JSON.stringify({ format })
45 });
46 result = await res.json();
47-
48- if (format === 'clipboard' && result?.text) {
49- await navigator.clipboard.writeText(result.text);
50- result = { success: true, message: 'Copied to clipboard!' };
51- }
52 } catch (err) {
53 result = { success: false, message: String(err) };
54 } finally {
55@@ -38,6 +60,28 @@
56 }
57 }
58
59+ function copyNow() {
60+ if (!pendingClipboardText) return;
61+ navigator.clipboard.writeText(pendingClipboardText).then(() => {
62+ clipboardState = 'copied';
63+ pendingClipboardText = null;
64+ setTimeout(() => { clipboardState = 'idle'; }, 2000);
65+ }).catch(() => {
66+ const textarea = document.createElement('textarea');
67+ textarea.value = pendingClipboardText!;
68+ textarea.style.position = 'fixed';
69+ textarea.style.left = '-9999px';
70+ document.body.appendChild(textarea);
71+ textarea.focus();
72+ textarea.select();
73+ document.execCommand('copy');
74+ document.body.removeChild(textarea);
75+ clipboardState = 'copied';
76+ pendingClipboardText = null;
77+ setTimeout(() => { clipboardState = 'idle'; }, 2000);
78+ });
79+ }
80+
81 function close() {
82 $showExportDialog = false;
83 result = null;
84@@ -67,12 +111,28 @@
85 </button>
86
87 <button
88- class="w-full text-left px-4 py-3 rounded-lg border border-border-strong hover:bg-hover/50 transition-colors group"
89- onclick={() => exportAs('clipboard')}
90- disabled={exporting}
91+ class="w-full text-left px-4 py-3 rounded-lg border transition-all {clipboardState === 'ready' ? 'border-accent-blue bg-accent-blue/10 ring-1 ring-accent-blue/30' : clipboardState === 'copied' ? 'border-accent-green bg-accent-green/10' : 'border-border-strong hover:bg-hover/50'} group"
92+ onclick={() => clipboardState === 'ready' ? copyNow() : exportAs('clipboard')}
93+ disabled={exporting || clipboardState === 'loading'}
94 >
95- <div class="text-sm font-medium text-primary group-hover:opacity-80">Copy to Clipboard</div>
96- <div class="text-xs text-tertiary mt-0.5">Copy structured review text for any AI tool</div>
97+ {#if clipboardState === 'loading'}
98+ <div class="text-sm font-medium text-primary flex items-center gap-2">
99+ <span class="animate-spin">⏳</span> Preparing content...
100+ </div>
101+ <div class="text-xs text-tertiary mt-0.5">Generating review text</div>
102+ {:else if clipboardState === 'ready'}
103+ <div class="text-sm font-medium text-accent-blue flex items-center gap-2">
104+ 📋 Content ready — click to copy
105+ </div>
106+ <div class="text-xs text-accent-blue/70 mt-0.5">Click now to copy to clipboard</div>
107+ {:else if clipboardState === 'copied'}
108+ <div class="text-sm font-medium text-accent-green flex items-center gap-2">
109+ ✓ Copied to clipboard!
110+ </div>
111+ {:else}
112+ <div class="text-sm font-medium text-primary group-hover:opacity-80">Copy to Clipboard</div>
113+ <div class="text-xs text-tertiary mt-0.5">Copy structured review text for any AI tool</div>
114+ {/if}
115 </button>
116
117 <button
+238,
-41
1@@ -1,5 +1,5 @@
2 <script lang="ts">
3- import { files, selectedFile, fileCommentCounts, toggleApproval } from '$lib/stores/files.ts';
4+ import { files, selectedFile, fileCommentCounts, toggleApproval, resetFile, contextFiles, toggleContextFile } from '$lib/stores/files.ts';
5
6 const statusColors: Record<string, string> = {
7 added: 'text-accent-green',
8@@ -15,6 +15,50 @@
9 renamed: 'R'
10 };
11
12+ let contextMenu = $state<{ x: number; y: number; path: string; status: string } | null>(null);
13+ let confirmingReset = $state<string | null>(null);
14+ let contextFileInput = $state('');
15+ let showContextInput = $state(false);
16+ let collapsedDirs = $state<Set<string>>(new Set());
17+
18+ interface FileGroup {
19+ dir: string;
20+ files: typeof $files;
21+ }
22+
23+ let groupedFiles = $derived.by(() => {
24+ const groups = new Map<string, typeof $files>();
25+ for (const file of $files) {
26+ const dir = getDirectory(file.path) || '.';
27+ if (!groups.has(dir)) groups.set(dir, []);
28+ groups.get(dir)!.push(file);
29+ }
30+ // Sort groups by directory name
31+ const sorted: FileGroup[] = [];
32+ for (const [dir, files] of groups) {
33+ sorted.push({ dir, files });
34+ }
35+ sorted.sort((a, b) => a.dir.localeCompare(b.dir));
36+ return sorted;
37+ });
38+
39+ function toggleDir(dir: string) {
40+ const next = new Set(collapsedDirs);
41+ if (next.has(dir)) {
42+ next.delete(dir);
43+ } else {
44+ next.add(dir);
45+ }
46+ collapsedDirs = next;
47+ }
48+
49+ async function addContextFromInput() {
50+ if (!contextFileInput.trim()) return;
51+ await toggleContextFile(contextFileInput.trim());
52+ contextFileInput = '';
53+ showContextInput = false;
54+ }
55+
56 function selectFile(path: string) {
57 $selectedFile = path;
58 }
59@@ -33,6 +77,28 @@
60 e.stopPropagation();
61 toggleApproval(path);
62 }
63+
64+ function handleContextMenu(e: MouseEvent, path: string, status: string) {
65+ e.preventDefault();
66+ e.stopPropagation();
67+ contextMenu = { x: e.clientX, y: e.clientY, path, status };
68+ }
69+
70+ function closeContextMenu() {
71+ contextMenu = null;
72+ }
73+
74+ function handleReset(path: string) {
75+ contextMenu = null;
76+ confirmingReset = path;
77+ }
78+
79+ async function confirmReset() {
80+ if (confirmingReset) {
81+ await resetFile(confirmingReset);
82+ confirmingReset = null;
83+ }
84+ }
85 </script>
86
87 <div class="h-full overflow-y-auto">
88@@ -40,49 +106,180 @@
89 Changed Files ({$files.length})
90 </div>
91 <div class="space-y-px">
92- {#each $files as file}
93- {@const isSelected = $selectedFile === file.path}
94- {@const commentCount = $fileCommentCounts[file.path] || 0}
95- <!-- svelte-ignore a11y_click_events_have_key_events -->
96- <!-- svelte-ignore a11y_no_static_element_interactions -->
97- <div
98- class="w-full text-left px-3 py-1.5 flex items-center gap-2 text-sm hover:bg-hover/50 transition-colors cursor-pointer {isSelected ? 'bg-hover/80 text-primary' : 'text-secondary'} {file.approved ? 'opacity-60' : ''}"
99- onclick={() => selectFile(file.path)}
100- >
101- <!-- Approve checkbox -->
102- <button
103- class="shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors {file.approved ? 'bg-green-600 border-green-600 text-white' : 'border-border-strong hover:border-accent-green text-transparent hover:text-accent-green'}"
104- onclick={(e) => handleApprove(e, file.path)}
105- title={file.approved ? 'Unapprove file' : 'Approve file'}
106+ {#each groupedFiles as group}
107+ {@const isCollapsed = collapsedDirs.has(group.dir)}
108+ {@const dirFileCount = group.files.length}
109+ <!-- Directory header -->
110+ {#if groupedFiles.length > 1}
111+ <!-- svelte-ignore a11y_click_events_have_key_events -->
112+ <!-- svelte-ignore a11y_no_static_element_interactions -->
113+ <div
114+ class="flex items-center gap-1 px-2 py-1 text-xs text-muted hover:text-secondary cursor-pointer hover:bg-hover/30 transition-colors select-none"
115+ onclick={() => toggleDir(group.dir)}
116 >
117- <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
118- <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
119- </svg>
120- </button>
121+ <span class="w-4 text-center transition-transform {isCollapsed ? '' : 'rotate-90'}">▸</span>
122+ <span class="truncate font-mono">{group.dir}</span>
123+ <span class="text-muted/60 ml-1">({dirFileCount})</span>
124+ </div>
125+ {/if}
126
127- <span class="font-mono text-xs font-bold w-4 text-center {statusColors[file.status]}">
128- {statusLabels[file.status]}
129- </span>
130- <span class="truncate flex-1 {file.approved ? 'line-through decoration-muted' : ''}">
131- {#if getDirectory(file.path)}
132- <span class="text-muted">{getDirectory(file.path)}/</span>
133- {/if}
134- <span class="font-medium">{getFilename(file.path)}</span>
135- </span>
136- <span class="text-xs text-muted tabular-nums shrink-0">
137- {#if file.additions > 0}
138- <span class="text-accent-green">+{file.additions}</span>
139- {/if}
140- {#if file.deletions > 0}
141- <span class="text-accent-red">-{file.deletions}</span>
142- {/if}
143- </span>
144- {#if commentCount > 0}
145- <span class="bg-accent-blue/20 text-accent-blue text-xs px-1.5 py-0.5 rounded-full font-medium">
146- {commentCount}
147- </span>
148- {/if}
149+ <!-- Files in this directory -->
150+ {#if !isCollapsed}
151+ {#each group.files as file}
152+ {@const isSelected = $selectedFile === file.path}
153+ {@const commentCount = $fileCommentCounts[file.path] || 0}
154+ <!-- svelte-ignore a11y_click_events_have_key_events -->
155+ <!-- svelte-ignore a11y_no_static_element_interactions -->
156+ <div
157+ class="w-full text-left py-1.5 flex items-center gap-2 text-sm hover:bg-hover/50 transition-colors cursor-pointer group {isSelected ? 'bg-hover/80 text-primary' : 'text-secondary'} {file.approved ? 'opacity-60' : ''} {groupedFiles.length > 1 ? 'pl-7 pr-3' : 'px-3'}"
158+ onclick={() => selectFile(file.path)}
159+ oncontextmenu={(e) => handleContextMenu(e, file.path, file.status)}
160+ >
161+ <!-- Approve checkbox -->
162+ <button
163+ class="shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors {file.approved ? 'bg-green-600 border-green-600 text-white' : 'border-border-strong hover:border-accent-green text-transparent hover:text-accent-green'}"
164+ onclick={(e) => handleApprove(e, file.path)}
165+ title={file.approved ? 'Unapprove file' : 'Approve file'}
166+ >
167+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
168+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
169+ </svg>
170+ </button>
171+
172+ <span class="font-mono text-xs font-bold w-4 text-center {statusColors[file.status]}">
173+ {statusLabels[file.status]}
174+ </span>
175+ <span class="truncate flex-1 {file.approved ? 'line-through decoration-muted' : ''}">
176+ <span class="font-medium">{getFilename(file.path)}</span>
177+ </span>
178+ <span class="text-xs text-muted tabular-nums shrink-0">
179+ {#if file.additions > 0}
180+ <span class="text-accent-green">+{file.additions}</span>
181+ {/if}
182+ {#if file.deletions > 0}
183+ <span class="text-accent-red">-{file.deletions}</span>
184+ {/if}
185+ </span>
186+ {#if commentCount > 0}
187+ <span class="bg-accent-blue/20 text-accent-blue text-xs px-1.5 py-0.5 rounded-full font-medium">
188+ {commentCount}
189+ </span>
190+ {/if}
191+ <!-- More actions button -->
192+ <button
193+ class="shrink-0 opacity-0 group-hover:opacity-100 text-muted hover:text-primary transition-all text-xs px-0.5"
194+ onclick={(e) => { e.stopPropagation(); handleContextMenu(e, file.path, file.status); }}
195+ title="More actions"
196+ >
197+ ⋯
198+ </button>
199+ </div>
200+ {/each}
201+ {/if}
202+ {/each}
203+ </div>
204+</div>
205+
206+<!-- Context files section -->
207+{#if $contextFiles.length > 0 || showContextInput}
208+ <div class="px-3 py-2 text-xs font-semibold text-tertiary uppercase tracking-wider border-t border-border mt-2">
209+ Context Files ({$contextFiles.length})
210+ </div>
211+ <div class="space-y-px px-1">
212+ {#each $contextFiles as ctxFile}
213+ <div class="flex items-center gap-2 px-2 py-1 text-xs text-secondary">
214+ <span class="text-muted">📎</span>
215+ <span class="truncate flex-1 font-mono">{ctxFile}</span>
216+ <button
217+ class="text-muted hover:text-accent-red transition-colors shrink-0"
218+ onclick={() => toggleContextFile(ctxFile)}
219+ title="Remove from context"
220+ >✗</button>
221 </div>
222 {/each}
223 </div>
224+{/if}
225+{#if showContextInput}
226+ <div class="px-3 py-2">
227+ <input
228+ type="text"
229+ class="w-full bg-surface text-primary border border-border-strong rounded px-2 py-1 text-xs focus:outline-none focus:border-accent-blue placeholder:text-muted"
230+ placeholder="path/to/file.ts"
231+ bind:value={contextFileInput}
232+ onkeydown={(e) => { if (e.key === 'Enter') addContextFromInput(); if (e.key === 'Escape') showContextInput = false; }}
233+ />
234+ </div>
235+{/if}
236+<div class="px-3 py-1">
237+ <button
238+ class="text-xs text-muted hover:text-secondary transition-colors"
239+ onclick={() => (showContextInput = !showContextInput)}
240+ >
241+ + Add context file
242+ </button>
243 </div>
244+
245+<!-- Context menu -->
246+{#if contextMenu}
247+ <!-- svelte-ignore a11y_click_events_have_key_events -->
248+ <!-- svelte-ignore a11y_no_static_element_interactions -->
249+ <div class="fixed inset-0 z-50" onclick={closeContextMenu}></div>
250+ <div
251+ class="fixed z-50 bg-panel border border-border-strong rounded-lg shadow-xl py-1 min-w-[180px]"
252+ style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
253+ >
254+ <button
255+ class="w-full text-left px-3 py-1.5 text-sm text-secondary hover:bg-hover transition-colors"
256+ onclick={() => { toggleApproval(contextMenu!.path); closeContextMenu(); }}
257+ >
258+ {$files.find(f => f.path === contextMenu!.path)?.approved ? '✗ Unapprove' : '✓ Approve'}
259+ </button>
260+ <button
261+ class="w-full text-left px-3 py-1.5 text-sm text-secondary hover:bg-hover transition-colors"
262+ onclick={() => { toggleContextFile(contextMenu!.path); closeContextMenu(); }}
263+ >
264+ {$contextFiles.includes(contextMenu!.path) ? '✗ Remove from context' : '📎 Add as context'}
265+ </button>
266+ <div class="border-t border-border my-1"></div>
267+ <button
268+ class="w-full text-left px-3 py-1.5 text-sm text-accent-red hover:bg-hover transition-colors"
269+ onclick={() => handleReset(contextMenu!.path)}
270+ >
271+ {contextMenu.status === 'added' ? '🗑 Delete file' : '↩ Reset file'}
272+ </button>
273+ </div>
274+{/if}
275+
276+<!-- Reset confirmation dialog -->
277+{#if confirmingReset}
278+ <!-- svelte-ignore a11y_click_events_have_key_events -->
279+ <!-- svelte-ignore a11y_no_static_element_interactions -->
280+ <div class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center" onclick={() => (confirmingReset = null)}>
281+ <!-- svelte-ignore a11y_click_events_have_key_events -->
282+ <!-- svelte-ignore a11y_no_static_element_interactions -->
283+ <div class="bg-panel border border-border-strong rounded-lg p-4 max-w-md shadow-xl" onclick={(e) => e.stopPropagation()}>
284+ <h3 class="text-primary font-medium mb-2">Reset file?</h3>
285+ <p class="text-sm text-secondary mb-1">
286+ {$files.find(f => f.path === confirmingReset)?.status === 'added'
287+ ? 'This will delete the file:'
288+ : 'This will discard all changes in:'}
289+ </p>
290+ <p class="text-sm text-primary font-mono mb-4">{confirmingReset}</p>
291+ <p class="text-xs text-accent-red mb-4">This action cannot be undone.</p>
292+ <div class="flex justify-end gap-2">
293+ <button
294+ class="text-sm px-3 py-1.5 rounded text-secondary hover:text-primary hover:bg-hover transition-colors"
295+ onclick={() => (confirmingReset = null)}
296+ >
297+ Cancel
298+ </button>
299+ <button
300+ class="text-sm px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
301+ onclick={confirmReset}
302+ >
303+ {$files.find(f => f.path === confirmingReset)?.status === 'added' ? 'Delete' : 'Reset'}
304+ </button>
305+ </div>
306+ </div>
307+ </div>
308+{/if}
+20,
-3
1@@ -2,8 +2,9 @@ import { writeFile } from 'fs/promises';
2 import { join } from 'path';
3 import { spawn } from 'child_process';
4 import type { ReviewExport, ReviewExportFile, ReviewExportThread } from '$lib/types/index.ts';
5+import { readFile } from 'fs/promises';
6 import { getRepoDir, getRepoInfo, getStatus, getFileContent } from './git.ts';
7-import { getReview, getThreads } from './review-store.ts';
8+import { getReview, getThreads, getContextFiles } from './review-store.ts';
9
10 export async function buildExport(): Promise<ReviewExport> {
11 const { branch, repoName } = await getRepoInfo();
12@@ -126,7 +127,7 @@ export async function exportToClaude(): Promise<{ success: boolean; message: str
13 return { success: false, message: 'No review comments to send. Add comments first.' };
14 }
15
16- const prompt = buildClaudePrompt(data);
17+ const prompt = await buildClaudePrompt(data);
18 const claudePath = findClaude();
19
20 return new Promise((resolve) => {
21@@ -168,7 +169,7 @@ export async function exportToClaude(): Promise<{ success: boolean; message: str
22 });
23 }
24
25-export function buildClaudePrompt(data: ReviewExport): string {
26+export async function buildClaudePrompt(data: ReviewExport): Promise<string> {
27 const lines: string[] = [
28 'I have completed a code review of the current changes. Please address each review comment below by making the necessary changes to the codebase.',
29 ''
30@@ -192,6 +193,22 @@ export function buildClaudePrompt(data: ReviewExport): string {
31 }
32 }
33
34+ // Include context files for additional reference
35+ const contextFilePaths = getContextFiles();
36+ if (contextFilePaths.length > 0) {
37+ lines.push('', '--- Additional Context Files ---', '');
38+ lines.push('The following files are provided as additional context for your reference when making changes:', '');
39+ const repoDir = getRepoDir();
40+ for (const filePath of contextFilePaths) {
41+ try {
42+ const content = await readFile(join(repoDir, filePath), 'utf-8');
43+ lines.push(`File: ${filePath}`, '```', content, '```', '');
44+ } catch {
45+ lines.push(`File: ${filePath} (could not read)`, '');
46+ }
47+ }
48+ }
49+
50 return lines.join('\n');
51 }
52
+47,
-1
1@@ -45,11 +45,12 @@ export async function getStatus(): Promise<FileChange[]> {
2 for (const f of status.renamed) {
3 addFile(f.to, 'renamed', false, f.from);
4 }
5- // Mark staged files
6+ // Mark staged files as both staged and approved
7 for (const f of status.staged) {
8 const existing = files.find((x) => x.path === f);
9 if (existing) {
10 existing.staged = true;
11+ existing.approved = true;
12 }
13 }
14
15@@ -343,6 +344,51 @@ export async function stageAll(): Promise<void> {
16 await g.add('-A');
17 }
18
19+export async function resetFile(filePath: string): Promise<void> {
20+ const g = getGit();
21+ const status = await g.status();
22+
23+ // Check if it's an untracked/new file
24+ if (status.not_added.includes(filePath) || status.created.includes(filePath)) {
25+ // Remove the file
26+ const { unlink } = await import('fs/promises');
27+ const { join } = await import('path');
28+ await unlink(join(getRepoDir(), filePath));
29+ // If it was staged, also remove from index
30+ if (status.created.includes(filePath)) {
31+ await g.raw(['rm', '--cached', filePath]);
32+ }
33+ } else {
34+ // Modified or deleted file: restore to HEAD version
35+ await g.checkout(['HEAD', '--', filePath]);
36+ }
37+}
38+
39+export async function resetHunk(filePath: string, hunkHeader: string): Promise<void> {
40+ const g = getGit();
41+ // Get the full diff for this file, then construct a reverse patch for just this hunk
42+ const diff = await g.diff(['HEAD', '--', filePath]);
43+ const hunks = diff.split(/(?=^@@)/m);
44+ const fileHeader = hunks[0] || '';
45+
46+ // Find the matching hunk
47+ const targetHunk = hunks.find((h) => h.startsWith(hunkHeader));
48+ if (!targetHunk) return;
49+
50+ // Build a patch with just this hunk and apply in reverse
51+ const patch = fileHeader + targetHunk;
52+ const { writeFile, unlink } = await import('fs/promises');
53+ const { join } = await import('path');
54+ const { tmpdir } = await import('os');
55+ const patchFile = join(tmpdir(), `lcr-patch-${Date.now()}.patch`);
56+ await writeFile(patchFile, patch);
57+ try {
58+ await g.raw(['apply', '--reverse', patchFile]);
59+ } finally {
60+ await unlink(patchFile).catch(() => {});
61+ }
62+}
63+
64 export async function commitStaged(message: string): Promise<string> {
65 const g = getGit();
66 const result = await g.commit(message);
+14,
-0
1@@ -4,6 +4,19 @@ import { randomUUID } from 'crypto';
2 const threads = new Map<string, ReviewThread>();
3 let reviewStatus: 'pending' | 'approved' | 'changes_requested' = 'pending';
4 let reviewSummary: string | undefined;
5+const contextFiles = new Set<string>();
6+
7+export function addContextFile(filePath: string): void {
8+ contextFiles.add(filePath);
9+}
10+
11+export function removeContextFile(filePath: string): void {
12+ contextFiles.delete(filePath);
13+}
14+
15+export function getContextFiles(): string[] {
16+ return Array.from(contextFiles);
17+}
18
19 export function addComment(
20 filePath: string,
21@@ -101,6 +114,7 @@ export function clearAll(): void {
22 threads.clear();
23 reviewStatus = 'pending';
24 reviewSummary = undefined;
25+ contextFiles.clear();
26 }
27
28 export function markOutdated(threadId: string, outdated: boolean): boolean {
+54,
-0
1@@ -16,6 +16,26 @@ export const selectedDiff = derived([diffFiles, selectedFile], ([$diffFiles, $se
2 });
3
4 export const fileCommentCounts = writable<Record<string, number>>({});
5+export const contextFiles = writable<string[]>([]);
6+
7+export async function loadContextFiles() {
8+ const res = await fetch('/api/context-files');
9+ const data = await res.json();
10+ if (data.files) contextFiles.set(data.files);
11+}
12+
13+export async function toggleContextFile(path: string) {
14+ let current: string[] = [];
15+ contextFiles.subscribe((v) => (current = v))();
16+ const isContext = current.includes(path);
17+ const res = await fetch('/api/context-files', {
18+ method: 'POST',
19+ headers: { 'Content-Type': 'application/json' },
20+ body: JSON.stringify({ path, action: isContext ? 'remove' : 'add' })
21+ });
22+ const data = await res.json();
23+ if (data.files) contextFiles.set(data.files);
24+}
25
26 export const approvedCount = derived(files, ($files) => {
27 const approved = $files.filter((f) => f.approved).length;
28@@ -104,6 +124,40 @@ export async function unapproveFile(path: string) {
29 }
30 }
31
32+export async function resetFile(path: string) {
33+ const res = await fetch('/api/files', {
34+ method: 'POST',
35+ headers: { 'Content-Type': 'application/json' },
36+ body: JSON.stringify({ path, action: 'reset' })
37+ });
38+ const data = await res.json();
39+ if (Array.isArray(data)) {
40+ files.set(data);
41+ await loadAllDiffs();
42+ // If the reset file was selected and is now gone, select another
43+ const currentSelected = await new Promise<string | null>((resolve) => {
44+ selectedFile.subscribe((v) => resolve(v))();
45+ });
46+ if (currentSelected === path) {
47+ const remaining = data as FileChange[];
48+ selectedFile.set(remaining.length > 0 ? remaining[0].path : null);
49+ }
50+ }
51+}
52+
53+export async function resetHunk(path: string, hunkHeader: string) {
54+ const res = await fetch('/api/files', {
55+ method: 'POST',
56+ headers: { 'Content-Type': 'application/json' },
57+ body: JSON.stringify({ path, action: 'reset-hunk', hunkHeader })
58+ });
59+ const data = await res.json();
60+ if (Array.isArray(data)) {
61+ files.set(data);
62+ await loadAllDiffs();
63+ }
64+}
65+
66 export async function toggleApproval(path: string) {
67 const currentFiles = await new Promise<FileChange[]>((resolve) => {
68 files.subscribe((v) => resolve(v))();
+30,
-1
1@@ -13,6 +13,29 @@
2
3 let loading = $state(true);
4 let error = $state<string | null>(null);
5+ let sidebarWidth = $state(288); // 18rem default
6+ let isResizingSidebar = $state(false);
7+
8+ function startSidebarResize(e: MouseEvent) {
9+ e.preventDefault();
10+ isResizingSidebar = true;
11+ const startX = e.clientX;
12+ const startWidth = sidebarWidth;
13+
14+ function onMouseMove(e: MouseEvent) {
15+ const newWidth = startWidth + (e.clientX - startX);
16+ sidebarWidth = Math.max(200, Math.min(600, newWidth));
17+ }
18+
19+ function onMouseUp() {
20+ isResizingSidebar = false;
21+ window.removeEventListener('mousemove', onMouseMove);
22+ window.removeEventListener('mouseup', onMouseUp);
23+ }
24+
25+ window.addEventListener('mousemove', onMouseMove);
26+ window.addEventListener('mouseup', onMouseUp);
27+ }
28
29 onMount(async () => {
30 try {
31@@ -59,8 +82,14 @@
32 <div class="flex-1 flex overflow-hidden">
33 <!-- Sidebar -->
34 {#if $sidebarOpen}
35- <aside class="w-72 shrink-0 bg-panel/50 border-r border-border overflow-hidden flex flex-col">
36+ <aside class="shrink-0 bg-panel/50 border-r border-border overflow-hidden flex flex-col relative" style="width: {sidebarWidth}px">
37 <FileTree />
38+ <!-- Resize handle -->
39+ <!-- svelte-ignore a11y_no_static_element_interactions -->
40+ <div
41+ class="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-accent-blue/50 transition-colors {isResizingSidebar ? 'bg-accent-blue/50' : ''}"
42+ onmousedown={startSidebarResize}
43+ ></div>
44 </aside>
45 {/if}
46
+24,
-0
1@@ -0,0 +1,24 @@
2+import { json } from '@sveltejs/kit';
3+import { addContextFile, removeContextFile, getContextFiles } from '$lib/server/review-store.ts';
4+import type { RequestHandler } from './$types.ts';
5+
6+export const GET: RequestHandler = async () => {
7+ return json({ files: getContextFiles() });
8+};
9+
10+export const POST: RequestHandler = async ({ request }) => {
11+ const body = await request.json();
12+ const { path, action } = body;
13+
14+ if (!path) {
15+ return json({ error: 'Missing path' }, { status: 400 });
16+ }
17+
18+ if (action === 'remove') {
19+ removeContextFile(path);
20+ } else {
21+ addContextFile(path);
22+ }
23+
24+ return json({ files: getContextFiles() });
25+};
+7,
-1
1@@ -1,5 +1,5 @@
2 import { json } from '@sveltejs/kit';
3-import { getStatus, stageFile, unstageFile } from '$lib/server/git.ts';
4+import { getStatus, stageFile, unstageFile, resetFile, resetHunk } from '$lib/server/git.ts';
5 import type { RequestHandler } from './$types.ts';
6
7 export const GET: RequestHandler = async () => {
8@@ -24,6 +24,12 @@ export const POST: RequestHandler = async ({ request }) => {
9 await stageFile(path);
10 } else if (action === 'unapprove') {
11 await unstageFile(path);
12+ } else if (action === 'reset') {
13+ await resetFile(path);
14+ } else if (action === 'reset-hunk') {
15+ const { hunkHeader } = body;
16+ if (!hunkHeader) return json({ error: 'Missing hunkHeader' }, { status: 400 });
17+ await resetHunk(path, hunkHeader);
18 } else {
19 return json({ error: 'Unknown action' }, { status: 400 });
20 }