@myrialabs/clopen 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/backend/chat/stream-manager.ts +106 -9
  2. package/backend/database/queries/project-queries.ts +1 -4
  3. package/backend/database/queries/session-queries.ts +36 -1
  4. package/backend/database/queries/snapshot-queries.ts +122 -0
  5. package/backend/database/utils/connection.ts +17 -11
  6. package/backend/engine/adapters/claude/stream.ts +14 -3
  7. package/backend/engine/types.ts +9 -0
  8. package/backend/index.ts +13 -2
  9. package/backend/mcp/config.ts +32 -6
  10. package/backend/snapshot/blob-store.ts +52 -72
  11. package/backend/snapshot/snapshot-service.ts +24 -0
  12. package/backend/terminal/stream-manager.ts +121 -131
  13. package/backend/ws/chat/stream.ts +14 -7
  14. package/backend/ws/engine/claude/accounts.ts +6 -8
  15. package/backend/ws/projects/crud.ts +72 -7
  16. package/backend/ws/sessions/crud.ts +119 -2
  17. package/backend/ws/system/operations.ts +14 -39
  18. package/backend/ws/terminal/persistence.ts +19 -33
  19. package/backend/ws/terminal/session.ts +37 -19
  20. package/bun.lock +6 -0
  21. package/frontend/components/auth/SetupPage.svelte +1 -1
  22. package/frontend/components/chat/input/ChatInput.svelte +22 -1
  23. package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
  24. package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
  25. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  26. package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
  27. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  28. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  29. package/frontend/components/files/FileNode.svelte +0 -15
  30. package/frontend/components/git/ChangesSection.svelte +104 -13
  31. package/frontend/components/history/HistoryModal.svelte +94 -19
  32. package/frontend/components/history/HistoryView.svelte +29 -36
  33. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  34. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  35. package/frontend/components/terminal/Terminal.svelte +5 -1
  36. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  37. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  38. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  39. package/frontend/services/chat/chat.service.ts +94 -23
  40. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  41. package/frontend/services/terminal/project.service.ts +4 -60
  42. package/frontend/services/terminal/terminal.service.ts +18 -27
  43. package/frontend/stores/core/app.svelte.ts +10 -2
  44. package/frontend/stores/core/sessions.svelte.ts +10 -1
  45. package/package.json +4 -2
@@ -3,6 +3,9 @@ import { onDestroy } from 'svelte';
3
3
  /**
4
4
  * Composable for managing placeholder and loading text animations
5
5
  * Combines placeholder typewriter effect and loading text rotation
6
+ *
7
+ * Both animations use a `destroyed` flag to prevent interval callbacks
8
+ * from mutating state after the owning component is torn down (HMR / navigation).
6
9
  */
7
10
 
8
11
  // ============================================================================
@@ -12,83 +15,77 @@ import { onDestroy } from 'svelte';
12
15
  export function usePlaceholderAnimation(placeholderTexts: string[]) {
13
16
  let currentPlaceholderIndex = $state(0);
14
17
  let placeholderText = $state('');
15
- let placeholderTypewriterInterval: number | null = null;
16
- let placeholderRotationInterval: number | null = null;
17
- let placeholderDeleteTimeout: number | null = null;
18
+ let destroyed = false;
19
+
20
+ // Track every active timer so stopPlaceholderAnimation can clear them all
21
+ let typewriterInterval: number | null = null;
22
+ let rotationInterval: number | null = null;
23
+ let deleteTimeout: number | null = null;
24
+ let deleteInterval: number | null = null;
18
25
 
19
- // Typewriter effect for placeholder
20
26
  function typewritePlaceholder(text: string) {
21
- if (placeholderTypewriterInterval) {
22
- clearInterval(placeholderTypewriterInterval);
23
- }
27
+ if (typewriterInterval) clearInterval(typewriterInterval);
28
+ typewriterInterval = null;
24
29
 
25
- let currentIndex = 0;
30
+ let idx = 0;
26
31
  placeholderText = '';
27
32
 
28
- placeholderTypewriterInterval = window.setInterval(() => {
29
- if (currentIndex < text.length) {
30
- placeholderText = text.substring(0, currentIndex + 1);
31
- currentIndex++;
33
+ typewriterInterval = window.setInterval(() => {
34
+ if (destroyed) { clearInterval(typewriterInterval!); typewriterInterval = null; return; }
35
+ if (idx < text.length) {
36
+ placeholderText = text.substring(0, idx + 1);
37
+ idx++;
32
38
  } else {
33
- clearInterval(placeholderTypewriterInterval!);
34
- placeholderTypewriterInterval = null;
39
+ clearInterval(typewriterInterval!);
40
+ typewriterInterval = null;
35
41
  }
36
- }, 20); // Typing speed
42
+ }, 20);
37
43
  }
38
44
 
39
- // Update placeholder with typewriter effect
40
45
  function updatePlaceholder() {
41
46
  const fullText = placeholderTexts[currentPlaceholderIndex];
42
47
 
43
- // Clear any existing delete timeout
44
- if (placeholderDeleteTimeout) {
45
- clearTimeout(placeholderDeleteTimeout);
46
- }
48
+ if (deleteTimeout) clearTimeout(deleteTimeout);
49
+ if (deleteInterval) clearInterval(deleteInterval);
50
+ deleteTimeout = null;
51
+ deleteInterval = null;
47
52
 
48
- // Use a tracked timeout that can be cleared properly
49
- placeholderDeleteTimeout = window.setTimeout(() => {
50
- // Clear current text with backspace effect
51
- let deleteInterval = window.setInterval(() => {
53
+ deleteTimeout = window.setTimeout(() => {
54
+ if (destroyed) return;
55
+ deleteTimeout = null;
56
+
57
+ deleteInterval = window.setInterval(() => {
58
+ if (destroyed) { clearInterval(deleteInterval!); deleteInterval = null; return; }
52
59
  if (placeholderText.length > 0) {
53
60
  placeholderText = placeholderText.substring(0, placeholderText.length - 1);
54
61
  } else {
55
- clearInterval(deleteInterval);
56
- // Start typing new text
62
+ clearInterval(deleteInterval!);
63
+ deleteInterval = null;
57
64
  typewritePlaceholder(fullText);
58
65
  }
59
- }, 15); // Delete speed
60
- }, 2000); // Wait 2 seconds before deleting
66
+ }, 15);
67
+ }, 2000);
61
68
  }
62
69
 
63
70
  function startPlaceholderAnimation() {
64
- // Clear any existing intervals first
65
71
  stopPlaceholderAnimation();
72
+ destroyed = false;
66
73
 
67
74
  currentPlaceholderIndex = Math.floor(Math.random() * placeholderTexts.length);
68
- // Initial placeholder
69
- const initialText = placeholderTexts[currentPlaceholderIndex];
70
- typewritePlaceholder(initialText);
75
+ typewritePlaceholder(placeholderTexts[currentPlaceholderIndex]);
71
76
 
72
- // Rotate placeholders
73
- placeholderRotationInterval = window.setInterval(() => {
77
+ rotationInterval = window.setInterval(() => {
78
+ if (destroyed) { clearInterval(rotationInterval!); rotationInterval = null; return; }
74
79
  currentPlaceholderIndex = (currentPlaceholderIndex + 1) % placeholderTexts.length;
75
80
  updatePlaceholder();
76
- }, 7000); // Change every 7 seconds
81
+ }, 7000);
77
82
  }
78
83
 
79
84
  function stopPlaceholderAnimation() {
80
- if (placeholderTypewriterInterval) {
81
- clearInterval(placeholderTypewriterInterval);
82
- placeholderTypewriterInterval = null;
83
- }
84
- if (placeholderRotationInterval) {
85
- clearInterval(placeholderRotationInterval);
86
- placeholderRotationInterval = null;
87
- }
88
- if (placeholderDeleteTimeout) {
89
- clearTimeout(placeholderDeleteTimeout);
90
- placeholderDeleteTimeout = null;
91
- }
85
+ if (typewriterInterval) { clearInterval(typewriterInterval); typewriterInterval = null; }
86
+ if (rotationInterval) { clearInterval(rotationInterval); rotationInterval = null; }
87
+ if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
88
+ if (deleteInterval) { clearInterval(deleteInterval); deleteInterval = null; }
92
89
  }
93
90
 
94
91
  function setStaticPlaceholder(text: string) {
@@ -96,15 +93,13 @@ export function usePlaceholderAnimation(placeholderTexts: string[]) {
96
93
  placeholderText = text;
97
94
  }
98
95
 
99
- // Cleanup on destroy
100
96
  onDestroy(() => {
97
+ destroyed = true;
101
98
  stopPlaceholderAnimation();
102
99
  });
103
100
 
104
101
  return {
105
- get placeholderText() {
106
- return placeholderText;
107
- },
102
+ get placeholderText() { return placeholderText; },
108
103
  startAnimation: startPlaceholderAnimation,
109
104
  stopAnimation: stopPlaceholderAnimation,
110
105
  setStaticPlaceholder
@@ -116,85 +111,106 @@ export function usePlaceholderAnimation(placeholderTexts: string[]) {
116
111
  // ============================================================================
117
112
 
118
113
  export function useLoadingTextAnimation(loadingTexts: string[]) {
119
- let currentLoadingText = $state('thinking');
120
- let visibleLoadingText = $state('thinking');
121
- let loadingTextIntervalId: number | null = null;
122
- let typewriterIntervalId: number | null = null;
123
-
124
- // Typewriter effect for smooth text transition
125
- function animateTextTransition(newText: string) {
126
- if (typewriterIntervalId) {
127
- clearInterval(typewriterIntervalId);
128
- }
114
+ let visibleLoadingText = $state('');
115
+ let currentFullText = '';
116
+ let destroyed = false;
117
+
118
+ let typewriterInterval: number | null = null;
119
+ let rotationInterval: number | null = null;
120
+
121
+ /**
122
+ * Typewriter: type characters one-by-one.
123
+ * Calls `onDone` when the full text has been typed.
124
+ */
125
+ function typeText(text: string, onDone?: () => void) {
126
+ if (typewriterInterval) clearInterval(typewriterInterval);
127
+ typewriterInterval = null;
128
+
129
+ let idx = 0;
130
+ visibleLoadingText = '';
131
+
132
+ typewriterInterval = window.setInterval(() => {
133
+ if (destroyed) { clearInterval(typewriterInterval!); typewriterInterval = null; return; }
134
+ if (idx < text.length) {
135
+ visibleLoadingText = text.substring(0, idx + 1);
136
+ idx++;
137
+ } else {
138
+ clearInterval(typewriterInterval!);
139
+ typewriterInterval = null;
140
+ onDone?.();
141
+ }
142
+ }, 40);
143
+ }
129
144
 
130
- const oldText = visibleLoadingText;
131
- let deleteIndex = oldText.length;
132
- let typeIndex = 0;
133
- let isDeleting = true;
134
-
135
- typewriterIntervalId = window.setInterval(() => {
136
- if (isDeleting) {
137
- // Delete characters
138
- if (deleteIndex > 0) {
139
- visibleLoadingText = oldText.substring(0, deleteIndex - 1);
140
- deleteIndex--;
141
- } else {
142
- // Finished deleting, start typing
143
- isDeleting = false;
144
- }
145
+ /**
146
+ * Backspace: delete characters one-by-one from the current visible text.
147
+ * Calls `onDone` when the text is fully erased.
148
+ */
149
+ function deleteText(onDone?: () => void) {
150
+ if (typewriterInterval) clearInterval(typewriterInterval);
151
+ typewriterInterval = null;
152
+
153
+ const snapshot = visibleLoadingText;
154
+ let len = snapshot.length;
155
+
156
+ typewriterInterval = window.setInterval(() => {
157
+ if (destroyed) { clearInterval(typewriterInterval!); typewriterInterval = null; return; }
158
+ if (len > 0) {
159
+ len--;
160
+ visibleLoadingText = snapshot.substring(0, len);
145
161
  } else {
146
- // Type new characters
147
- if (typeIndex < newText.length) {
148
- visibleLoadingText = newText.substring(0, typeIndex + 1);
149
- typeIndex++;
150
- } else {
151
- // Finished typing
152
- clearInterval(typewriterIntervalId!);
153
- typewriterIntervalId = null;
154
- }
162
+ clearInterval(typewriterInterval!);
163
+ typewriterInterval = null;
164
+ onDone?.();
155
165
  }
156
- }, 50); // Adjust speed here (lower = faster)
166
+ }, 40);
167
+ }
168
+
169
+ function pickNextText(): string {
170
+ let next = currentFullText;
171
+ while (next === currentFullText && loadingTexts.length > 1) {
172
+ next = loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
173
+ }
174
+ return next;
175
+ }
176
+
177
+ /** Delete current text, then type new text */
178
+ function transitionTo(newText: string) {
179
+ currentFullText = newText;
180
+ deleteText(() => {
181
+ if (destroyed) return;
182
+ typeText(newText);
183
+ });
157
184
  }
158
185
 
159
186
  function startLoadingAnimation() {
160
- // Clear any existing intervals first to prevent duplication
161
187
  stopLoadingAnimation();
188
+ destroyed = false;
162
189
 
163
- // Start loading text rotation with typewriter effect
164
- currentLoadingText = loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
165
- visibleLoadingText = currentLoadingText;
166
- loadingTextIntervalId = window.setInterval(() => {
167
- // Get a different text than the current one
168
- let newText = currentLoadingText;
169
- while (newText === currentLoadingText && loadingTexts.length > 1) {
170
- newText = loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
171
- }
172
- currentLoadingText = newText;
173
- animateTextTransition(newText);
190
+ // Type the initial text character-by-character
191
+ currentFullText = loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
192
+ typeText(currentFullText);
193
+
194
+ // Rotate to a new random text periodically
195
+ rotationInterval = window.setInterval(() => {
196
+ if (destroyed) { clearInterval(rotationInterval!); rotationInterval = null; return; }
197
+ transitionTo(pickNextText());
174
198
  }, 15000);
175
199
  }
176
200
 
177
201
  function stopLoadingAnimation() {
178
- // Clear loading text interval
179
- if (loadingTextIntervalId) {
180
- window.clearInterval(loadingTextIntervalId);
181
- loadingTextIntervalId = null;
182
- }
183
- if (typewriterIntervalId) {
184
- window.clearInterval(typewriterIntervalId);
185
- typewriterIntervalId = null;
186
- }
202
+ if (typewriterInterval) { clearInterval(typewriterInterval); typewriterInterval = null; }
203
+ if (rotationInterval) { clearInterval(rotationInterval); rotationInterval = null; }
204
+ visibleLoadingText = '';
187
205
  }
188
206
 
189
- // Cleanup on destroy
190
207
  onDestroy(() => {
208
+ destroyed = true;
191
209
  stopLoadingAnimation();
192
210
  });
193
211
 
194
212
  return {
195
- get visibleLoadingText() {
196
- return visibleLoadingText;
197
- },
213
+ get visibleLoadingText() { return visibleLoadingText; },
198
214
  startAnimation: startLoadingAnimation,
199
215
  stopAnimation: stopLoadingAnimation
200
216
  };
@@ -12,8 +12,18 @@ export function useTextareaResize() {
12
12
  // Reset height to auto to get accurate scrollHeight
13
13
  textareaElement.style.height = 'auto';
14
14
 
15
- // If content is empty or only whitespace, keep at minimum height
15
+ // If content is empty, measure placeholder height instead
16
16
  if (!messageText || !messageText.trim()) {
17
+ const placeholder = textareaElement.placeholder;
18
+ if (placeholder) {
19
+ // Temporarily set value to placeholder to measure wrapped height
20
+ // (native placeholder doesn't affect scrollHeight)
21
+ textareaElement.value = placeholder;
22
+ const scrollHeight = textareaElement.scrollHeight;
23
+ textareaElement.value = '';
24
+ const newHeight = Math.min(scrollHeight, MAX_HEIGHT_PX);
25
+ textareaElement.style.height = newHeight + 'px';
26
+ }
17
27
  return;
18
28
  }
19
29
 
@@ -64,6 +64,19 @@
64
64
  }
65
65
  });
66
66
  });
67
+
68
+ // Force reactive tracking for assistant text streaming.
69
+ // Without an explicit $effect that reads partialText, Svelte 5's derived chain
70
+ // may not re-render the component when partialText changes on a proxied object.
71
+ // Reasoning gets this implicitly via the auto-scroll effect above.
72
+ $effect(() => {
73
+ if (roleCategory !== 'assistant') return;
74
+ if (message.type !== 'stream_event') return;
75
+ if (!('partialText' in message)) return;
76
+ // Reading partialText subscribes this effect to changes,
77
+ // which forces the component to re-evaluate its derived values
78
+ const _track = message.partialText;
79
+ });
67
80
  </script>
68
81
 
69
82
  <div class="relative overflow-hidden">
@@ -259,7 +259,7 @@
259
259
  <div class="flex items-center gap-1">
260
260
  <button
261
261
  onclick={toggleExpand}
262
- class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
262
+ class="flex p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
263
263
  title={todoPanelState.isExpanded ? 'Collapse' : 'Expand'}
264
264
  >
265
265
  <Icon
@@ -269,7 +269,7 @@
269
269
  </button>
270
270
  <button
271
271
  onclick={minimize}
272
- class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
272
+ class="flex p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
273
273
  title="Minimize"
274
274
  >
275
275
  <Icon name="lucide:minus" class="w-4 h-4 text-slate-600 dark:text-slate-400" />
@@ -41,6 +41,11 @@
41
41
  let showDeleteFolder = $state(false);
42
42
  let folderToDelete: FileItem | null = $state(null);
43
43
  let deleteFolderConfirmName = $state('');
44
+ let showHidden = $state(false);
45
+
46
+ const filteredItems = $derived(
47
+ showHidden ? items : items.filter(item => !item.name.startsWith('.'))
48
+ );
44
49
 
45
50
  // Derived: whether directory access is restricted
46
51
  const hasRestrictions = $derived(systemSettings.allowedBasePaths && systemSettings.allowedBasePaths.length > 0);
@@ -611,6 +616,14 @@
611
616
  </div>
612
617
 
613
618
  <div class="flex items-center space-x-2">
619
+ <button
620
+ onclick={() => showHidden = !showHidden}
621
+ class="px-3 py-1.5 text-xs rounded-lg transition-colors {showHidden ? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300' : 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300'}"
622
+ title={showHidden ? 'Hide hidden folders' : 'Show hidden folders'}
623
+ >
624
+ <Icon name={showHidden ? 'lucide:eye' : 'lucide:eye-off'} class="inline sm:mr-1" />
625
+ <span class="hidden sm:inline">Hidden</span>
626
+ </button>
614
627
  <button
615
628
  onclick={() => showCreateFolder = true}
616
629
  class="px-3 py-1.5 text-xs rounded-lg bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 transition-colors"
@@ -651,24 +664,24 @@
651
664
  </Button>
652
665
  </div>
653
666
  </div>
654
- {:else if showLoadingSpinner && items.length === 0}
667
+ {:else if showLoadingSpinner && filteredItems.length === 0}
655
668
  <div class="flex items-center justify-center py-12">
656
669
  <div class="text-center">
657
670
  <div class="animate-spin rounded-full h-8 w-8 border-2 border-violet-500 border-t-transparent mx-auto mb-4"></div>
658
671
  <p class="text-slate-600 dark:text-slate-400">Loading directory...</p>
659
672
  </div>
660
673
  </div>
661
- {:else if items.length === 0}
674
+ {:else if filteredItems.length === 0}
662
675
  <div class="flex items-center justify-center py-12">
663
676
  <div class="text-center">
664
677
  <Icon name="lucide:folder-x" class="text-4xl text-slate-400 mx-auto mb-4" />
665
678
  <p class="text-slate-600 dark:text-slate-400">No folders found</p>
666
- <p class="text-sm text-slate-500 dark:text-slate-500 mt-2">This directory doesn't contain any subdirectories</p>
679
+ <p class="text-sm text-slate-500 dark:text-slate-500 mt-2">{items.length > 0 ? 'Toggle "Hidden" to show hidden folders' : 'This directory doesn\'t contain any subdirectories'}</p>
667
680
  </div>
668
681
  </div>
669
682
  {:else}
670
683
  <div class="space-y-2 transition-opacity duration-300 {loading ? 'opacity-75' : 'opacity-100'}">
671
- {#each items as item (item.path)}
684
+ {#each filteredItems as item (item.path)}
672
685
  <div
673
686
  class="flex items-center space-x-3 py-3 px-4 rounded-xl border transition-all duration-200 cursor-pointer {selectedPath === item.path
674
687
  ? 'bg-violet-50 dark:bg-violet-900/20 border-violet-200 dark:border-violet-700'
@@ -226,24 +226,26 @@
226
226
  </div>
227
227
  </div>
228
228
  {/if}
229
-
230
- <div class="flex justify-end gap-3 pt-2">
231
- {#if showCancel}
229
+
230
+ {#if !children}
231
+ <div class="flex justify-end gap-3 pt-2">
232
+ {#if showCancel}
233
+ <button
234
+ onclick={handleCancel}
235
+ class="px-6 py-2.5 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 font-semibold"
236
+ >
237
+ {cancelText}
238
+ </button>
239
+ {/if}
232
240
  <button
233
- onclick={handleCancel}
234
- class="px-6 py-2.5 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 font-semibold"
241
+ onclick={handleConfirm}
242
+ disabled={confirmDisabled}
243
+ class="px-6 py-2.5 {colors.button} rounded-lg transition-all duration-200 font-semibold focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-current"
235
244
  >
236
- {cancelText}
245
+ {confirmText}
237
246
  </button>
238
- {/if}
239
- <button
240
- onclick={handleConfirm}
241
- disabled={confirmDisabled}
242
- class="px-6 py-2.5 {colors.button} rounded-lg transition-all duration-200 font-semibold focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-current"
243
- >
244
- {confirmText}
245
- </button>
246
- </div>
247
+ </div>
248
+ {/if}
247
249
  </div>
248
250
  </div>
249
251
  {/if}
@@ -116,14 +116,6 @@
116
116
  onAction?.(action, file);
117
117
  }
118
118
 
119
- function formatFileSize(bytes: number): string {
120
- if (bytes === 0) return '0 B';
121
- const k = 1024;
122
- const sizes = ['B', 'KB', 'MB', 'GB'];
123
- const i = Math.floor(Math.log(bytes) / Math.log(k));
124
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
125
- }
126
-
127
119
  // Close menu when clicking outside
128
120
  onMount(() => {
129
121
  function handleClickOutside(event: MouseEvent) {
@@ -184,13 +176,6 @@
184
176
  {/if}
185
177
  </span>
186
178
 
187
- <!-- File metadata -->
188
- {#if file.type === 'file'}
189
- <span class="flex-shrink-0 text-xs text-slate-400 dark:text-slate-500 lg:group-hover:hidden">
190
- {formatFileSize(file.size || 0)}
191
- </span>
192
- {/if}
193
-
194
179
  <!-- Actions menu (always visible, triggered by click) -->
195
180
  <div class="flex-shrink-0">
196
181
  <div class="relative">
@@ -27,12 +27,78 @@
27
27
  onStageAll, onUnstageAll, onDiscardAll,
28
28
  onViewDiff, onResolve
29
29
  }: Props = $props();
30
+
31
+ // Virtual scroll — only render visible items when list is large
32
+ const ITEM_HEIGHT = 32;
33
+ const BUFFER = 10;
34
+ const VIRTUALIZE_THRESHOLD = 200;
35
+
36
+ let scrollEl = $state<HTMLDivElement>();
37
+ let headerEl = $state<HTMLDivElement>();
38
+ let scrollTop = $state(0);
39
+ let containerHeight = $state(384);
40
+ let panelHeight = $state(0);
41
+
42
+ const shouldVirtualize = $derived(files.length > VIRTUALIZE_THRESHOLD);
43
+ const visibleStart = $derived(
44
+ shouldVirtualize ? Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER) : 0
45
+ );
46
+ const visibleEnd = $derived(
47
+ shouldVirtualize
48
+ ? Math.min(files.length, Math.ceil((scrollTop + containerHeight) / ITEM_HEIGHT) + BUFFER)
49
+ : files.length
50
+ );
51
+ const visibleFiles = $derived(files.slice(visibleStart, visibleEnd));
52
+ const topPad = $derived(visibleStart * ITEM_HEIGHT);
53
+ const bottomPad = $derived(Math.max(0, (files.length - visibleEnd) * ITEM_HEIGHT));
54
+
55
+ // Dynamic max-height: fill panel minus section header — no gap
56
+ const headerH = $derived(headerEl?.offsetHeight ?? 48);
57
+ const scrollMaxH = $derived(panelHeight > 0 ? Math.max(128, panelHeight - headerH) : 384);
58
+
59
+ // Track nearest scrollable ancestor size via ResizeObserver
60
+ $effect(() => {
61
+ const el = scrollEl;
62
+ if (!el) return;
63
+
64
+ let parent = el.parentElement;
65
+ while (parent && parent !== document.body) {
66
+ const ov = getComputedStyle(parent).overflowY;
67
+ if (ov === 'auto' || ov === 'scroll') break;
68
+ parent = parent.parentElement;
69
+ }
70
+ if (!parent || parent === document.body) return;
71
+
72
+ const obs = new ResizeObserver(() => {
73
+ panelHeight = parent!.clientHeight;
74
+ containerHeight = el.clientHeight || 384;
75
+ });
76
+ obs.observe(parent);
77
+ panelHeight = parent.clientHeight;
78
+ containerHeight = el.clientHeight || 384;
79
+
80
+ return () => obs.disconnect();
81
+ });
82
+
83
+ // Reset scroll position when section is expanded (container remounts)
84
+ $effect(() => {
85
+ if (!isCollapsed) {
86
+ scrollTop = 0;
87
+ }
88
+ });
89
+
90
+ function onScroll(e: Event) {
91
+ const el = e.currentTarget as HTMLDivElement;
92
+ scrollTop = el.scrollTop;
93
+ containerHeight = el.clientHeight;
94
+ }
30
95
  </script>
31
96
 
32
97
  {#if files.length > 0}
33
98
  <div class="mb-1">
34
99
  <!-- Section header -->
35
100
  <div
101
+ bind:this={headerEl}
36
102
  onclick={() => isCollapsed = !isCollapsed}
37
103
  class="group flex items-center gap-2 py-3 px-2 cursor-pointer select-none hover:bg-slate-100 dark:hover:bg-slate-800/40 rounded-md transition-colors">
38
104
  <div
@@ -87,19 +153,44 @@
87
153
 
88
154
  <!-- Files list -->
89
155
  {#if !isCollapsed}
90
- <div class="ml-2">
91
- {#each files as file (file.path)}
92
- <FileChangeItem
93
- {file}
94
- {section}
95
- {onStage}
96
- {onUnstage}
97
- {onDiscard}
98
- {onViewDiff}
99
- {onResolve}
100
- />
101
- {/each}
102
- </div>
156
+ {#if shouldVirtualize}
157
+ <div
158
+ class="ml-2 overflow-y-auto"
159
+ style="max-height: {scrollMaxH}px"
160
+ bind:this={scrollEl}
161
+ onscroll={onScroll}
162
+ >
163
+ <div style="padding-top: {topPad}px; padding-bottom: {bottomPad}px;">
164
+ {#each visibleFiles as file (file.path)}
165
+ <div style="height: {ITEM_HEIGHT}px" class="overflow-hidden">
166
+ <FileChangeItem
167
+ {file}
168
+ {section}
169
+ {onStage}
170
+ {onUnstage}
171
+ {onDiscard}
172
+ {onViewDiff}
173
+ {onResolve}
174
+ />
175
+ </div>
176
+ {/each}
177
+ </div>
178
+ </div>
179
+ {:else}
180
+ <div class="ml-2">
181
+ {#each files as file (file.path)}
182
+ <FileChangeItem
183
+ {file}
184
+ {section}
185
+ {onStage}
186
+ {onUnstage}
187
+ {onDiscard}
188
+ {onViewDiff}
189
+ {onResolve}
190
+ />
191
+ {/each}
192
+ </div>
193
+ {/if}
103
194
  {/if}
104
195
  </div>
105
196
  {/if}