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}