FileTree.svelte
1<script lang="ts">
2 import { files, selectedFile, fileCommentCounts, toggleApproval, resetFile, contextFiles, toggleContextFile } from '$lib/stores/files';
3
4 const statusColors: Record<string, string> = {
5 added: 'text-accent-green',
6 modified: 'text-accent-yellow',
7 deleted: 'text-accent-red',
8 renamed: 'text-accent-blue'
9 };
10
11 const statusLabels: Record<string, string> = {
12 added: 'A',
13 modified: 'M',
14 deleted: 'D',
15 renamed: 'R'
16 };
17
18 let contextMenu = $state<{ x: number; y: number; path: string; status: string } | null>(null);
19 let confirmingReset = $state<string | null>(null);
20 let contextFileInput = $state('');
21 let showContextInput = $state(false);
22 let collapsedDirs = $state<Set<string>>(new Set());
23
24 interface FileGroup {
25 dir: string;
26 files: typeof $files;
27 }
28
29 let groupedFiles = $derived.by(() => {
30 const groups = new Map<string, typeof $files>();
31 for (const file of $files) {
32 const dir = getDirectory(file.path) || '.';
33 if (!groups.has(dir)) groups.set(dir, []);
34 groups.get(dir)!.push(file);
35 }
36 // Sort groups by directory name
37 const sorted: FileGroup[] = [];
38 for (const [dir, files] of groups) {
39 sorted.push({ dir, files });
40 }
41 sorted.sort((a, b) => a.dir.localeCompare(b.dir));
42 return sorted;
43 });
44
45 function toggleDir(dir: string) {
46 const next = new Set(collapsedDirs);
47 if (next.has(dir)) {
48 next.delete(dir);
49 } else {
50 next.add(dir);
51 }
52 collapsedDirs = next;
53 }
54
55 async function addContextFromInput() {
56 if (!contextFileInput.trim()) return;
57 await toggleContextFile(contextFileInput.trim());
58 contextFileInput = '';
59 showContextInput = false;
60 }
61
62 function selectFile(path: string) {
63 $selectedFile = path;
64 }
65
66 function getFilename(path: string): string {
67 return path.split('/').pop() || path;
68 }
69
70 function getDirectory(path: string): string {
71 const parts = path.split('/');
72 if (parts.length <= 1) return '';
73 return parts.slice(0, -1).join('/');
74 }
75
76 function handleApprove(e: Event, path: string) {
77 e.stopPropagation();
78 toggleApproval(path);
79 }
80
81 function handleContextMenu(e: MouseEvent, path: string, status: string) {
82 e.preventDefault();
83 e.stopPropagation();
84 contextMenu = { x: e.clientX, y: e.clientY, path, status };
85 }
86
87 function closeContextMenu() {
88 contextMenu = null;
89 }
90
91 function handleReset(path: string) {
92 contextMenu = null;
93 confirmingReset = path;
94 }
95
96 async function confirmReset() {
97 if (confirmingReset) {
98 await resetFile(confirmingReset);
99 confirmingReset = null;
100 }
101 }
102</script>
103
104<div class="h-full overflow-y-auto">
105 <div class="px-3 py-2 text-xs font-semibold text-tertiary uppercase tracking-wider">
106 Changed Files ({$files.length})
107 </div>
108 <div class="space-y-px">
109 {#each groupedFiles as group}
110 {@const isCollapsed = collapsedDirs.has(group.dir)}
111 {@const dirFileCount = group.files.length}
112 <!-- Directory header -->
113 {#if groupedFiles.length > 1}
114 <!-- svelte-ignore a11y_click_events_have_key_events -->
115 <!-- svelte-ignore a11y_no_static_element_interactions -->
116 <div
117 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"
118 onclick={() => toggleDir(group.dir)}
119 >
120 <span class="w-4 text-center transition-transform {isCollapsed ? '' : 'rotate-90'}">▸</span>
121 <span class="truncate font-mono">{group.dir}</span>
122 <span class="text-muted/60 ml-1">({dirFileCount})</span>
123 </div>
124 {/if}
125
126 <!-- Files in this directory -->
127 {#if !isCollapsed}
128 {#each group.files as file}
129 {@const isSelected = $selectedFile === file.path}
130 {@const commentCount = $fileCommentCounts[file.path] || 0}
131 <!-- svelte-ignore a11y_click_events_have_key_events -->
132 <!-- svelte-ignore a11y_no_static_element_interactions -->
133 <div
134 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'}"
135 onclick={() => selectFile(file.path)}
136 oncontextmenu={(e) => handleContextMenu(e, file.path, file.status)}
137 >
138 <!-- Approve checkbox -->
139 <button
140 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'}"
141 onclick={(e) => handleApprove(e, file.path)}
142 title={file.approved ? 'Unapprove file' : 'Approve file'}
143 >
144 <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
145 <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
146 </svg>
147 </button>
148
149 <span class="font-mono text-xs font-bold w-4 text-center {statusColors[file.status]}">
150 {statusLabels[file.status]}
151 </span>
152 <span class="truncate flex-1 {file.approved ? 'line-through decoration-muted' : ''}">
153 <span class="font-medium">{getFilename(file.path)}</span>
154 </span>
155 <span class="text-xs text-muted tabular-nums shrink-0">
156 {#if file.additions > 0}
157 <span class="text-accent-green">+{file.additions}</span>
158 {/if}
159 {#if file.deletions > 0}
160 <span class="text-accent-red">-{file.deletions}</span>
161 {/if}
162 </span>
163 {#if commentCount > 0}
164 <span class="bg-accent-blue/20 text-accent-blue text-xs px-1.5 py-0.5 rounded-full font-medium">
165 {commentCount}
166 </span>
167 {/if}
168 <!-- More actions button -->
169 <button
170 class="shrink-0 opacity-0 group-hover:opacity-100 text-muted hover:text-primary transition-all text-xs px-0.5"
171 onclick={(e) => { e.stopPropagation(); handleContextMenu(e, file.path, file.status); }}
172 title="More actions"
173 >
174 ⋯
175 </button>
176 </div>
177 {/each}
178 {/if}
179 {/each}
180 </div>
181</div>
182
183<!-- Context files section -->
184{#if $contextFiles.length > 0 || showContextInput}
185 <div class="px-3 py-2 text-xs font-semibold text-tertiary uppercase tracking-wider border-t border-border mt-2">
186 Context Files ({$contextFiles.length})
187 </div>
188 <div class="space-y-px px-1">
189 {#each $contextFiles as ctxFile}
190 <div class="flex items-center gap-2 px-2 py-1 text-xs text-secondary">
191 <span class="text-muted">📎</span>
192 <span class="truncate flex-1 font-mono">{ctxFile}</span>
193 <button
194 class="text-muted hover:text-accent-red transition-colors shrink-0"
195 onclick={() => toggleContextFile(ctxFile)}
196 title="Remove from context"
197 >✗</button>
198 </div>
199 {/each}
200 </div>
201{/if}
202{#if showContextInput}
203 <div class="px-3 py-2">
204 <input
205 type="text"
206 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"
207 placeholder="path/to/file.ts"
208 bind:value={contextFileInput}
209 onkeydown={(e) => { if (e.key === 'Enter') addContextFromInput(); if (e.key === 'Escape') showContextInput = false; }}
210 />
211 </div>
212{/if}
213<div class="px-3 py-1">
214 <button
215 class="text-xs text-muted hover:text-secondary transition-colors"
216 onclick={() => (showContextInput = !showContextInput)}
217 >
218 + Add context file
219 </button>
220</div>
221
222<!-- Context menu -->
223{#if contextMenu}
224 <!-- svelte-ignore a11y_click_events_have_key_events -->
225 <!-- svelte-ignore a11y_no_static_element_interactions -->
226 <div class="fixed inset-0 z-50" onclick={closeContextMenu}></div>
227 <div
228 class="fixed z-50 bg-panel border border-border-strong rounded-lg shadow-xl py-1 min-w-[180px]"
229 style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
230 >
231 <button
232 class="w-full text-left px-3 py-1.5 text-sm text-secondary hover:bg-hover transition-colors"
233 onclick={() => { toggleApproval(contextMenu!.path); closeContextMenu(); }}
234 >
235 {$files.find(f => f.path === contextMenu!.path)?.approved ? '✗ Unapprove' : '✓ Approve'}
236 </button>
237 <button
238 class="w-full text-left px-3 py-1.5 text-sm text-secondary hover:bg-hover transition-colors"
239 onclick={() => { toggleContextFile(contextMenu!.path); closeContextMenu(); }}
240 >
241 {$contextFiles.includes(contextMenu!.path) ? '✗ Remove from context' : '📎 Add as context'}
242 </button>
243 <div class="border-t border-border my-1"></div>
244 <button
245 class="w-full text-left px-3 py-1.5 text-sm text-accent-red hover:bg-hover transition-colors"
246 onclick={() => handleReset(contextMenu!.path)}
247 >
248 {contextMenu.status === 'added' ? '🗑 Delete file' : '↩ Reset file'}
249 </button>
250 </div>
251{/if}
252
253<!-- Reset confirmation dialog -->
254{#if confirmingReset}
255 <!-- svelte-ignore a11y_click_events_have_key_events -->
256 <!-- svelte-ignore a11y_no_static_element_interactions -->
257 <div class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center" onclick={() => (confirmingReset = null)}>
258 <!-- svelte-ignore a11y_click_events_have_key_events -->
259 <!-- svelte-ignore a11y_no_static_element_interactions -->
260 <div class="bg-panel border border-border-strong rounded-lg p-4 max-w-md shadow-xl" onclick={(e) => e.stopPropagation()}>
261 <h3 class="text-primary font-medium mb-2">Reset file?</h3>
262 <p class="text-sm text-secondary mb-1">
263 {$files.find(f => f.path === confirmingReset)?.status === 'added'
264 ? 'This will delete the file:'
265 : 'This will discard all changes in:'}
266 </p>
267 <p class="text-sm text-primary font-mono mb-4">{confirmingReset}</p>
268 <p class="text-xs text-accent-red mb-4">This action cannot be undone.</p>
269 <div class="flex justify-end gap-2">
270 <button
271 class="text-sm px-3 py-1.5 rounded text-secondary hover:text-primary hover:bg-hover transition-colors"
272 onclick={() => (confirmingReset = null)}
273 >
274 Cancel
275 </button>
276 <button
277 class="text-sm px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
278 onclick={confirmReset}
279 >
280 {$files.find(f => f.path === confirmingReset)?.status === 'added' ? 'Delete' : 'Reset'}
281 </button>
282 </div>
283 </div>
284 </div>
285{/if}