@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.
- package/backend/chat/stream-manager.ts +106 -9
- package/backend/database/queries/project-queries.ts +1 -4
- package/backend/database/queries/session-queries.ts +36 -1
- package/backend/database/queries/snapshot-queries.ts +122 -0
- package/backend/database/utils/connection.ts +17 -11
- package/backend/engine/adapters/claude/stream.ts +14 -3
- package/backend/engine/types.ts +9 -0
- package/backend/index.ts +13 -2
- package/backend/mcp/config.ts +32 -6
- package/backend/snapshot/blob-store.ts +52 -72
- package/backend/snapshot/snapshot-service.ts +24 -0
- package/backend/terminal/stream-manager.ts +121 -131
- package/backend/ws/chat/stream.ts +14 -7
- package/backend/ws/engine/claude/accounts.ts +6 -8
- package/backend/ws/projects/crud.ts +72 -7
- package/backend/ws/sessions/crud.ts +119 -2
- package/backend/ws/system/operations.ts +14 -39
- package/backend/ws/terminal/persistence.ts +19 -33
- package/backend/ws/terminal/session.ts +37 -19
- package/bun.lock +6 -0
- package/frontend/components/auth/SetupPage.svelte +1 -1
- package/frontend/components/chat/input/ChatInput.svelte +22 -1
- package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
- package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
- package/frontend/components/chat/message/MessageBubble.svelte +13 -0
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
- package/frontend/components/common/form/FolderBrowser.svelte +17 -4
- package/frontend/components/common/overlay/Dialog.svelte +17 -15
- package/frontend/components/files/FileNode.svelte +0 -15
- package/frontend/components/git/ChangesSection.svelte +104 -13
- package/frontend/components/history/HistoryModal.svelte +94 -19
- package/frontend/components/history/HistoryView.svelte +29 -36
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
- package/frontend/components/terminal/Terminal.svelte +5 -1
- package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
- package/frontend/components/workspace/MobileNavigator.svelte +57 -10
- package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
- package/frontend/services/chat/chat.service.ts +94 -23
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/services/terminal/project.service.ts +4 -60
- package/frontend/services/terminal/terminal.service.ts +18 -27
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +10 -1
- 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
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
22
|
-
|
|
23
|
-
}
|
|
27
|
+
if (typewriterInterval) clearInterval(typewriterInterval);
|
|
28
|
+
typewriterInterval = null;
|
|
24
29
|
|
|
25
|
-
let
|
|
30
|
+
let idx = 0;
|
|
26
31
|
placeholderText = '';
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
34
|
-
|
|
39
|
+
clearInterval(typewriterInterval!);
|
|
40
|
+
typewriterInterval = null;
|
|
35
41
|
}
|
|
36
|
-
}, 20);
|
|
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
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
if (deleteTimeout) clearTimeout(deleteTimeout);
|
|
49
|
+
if (deleteInterval) clearInterval(deleteInterval);
|
|
50
|
+
deleteTimeout = null;
|
|
51
|
+
deleteInterval = null;
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
62
|
+
clearInterval(deleteInterval!);
|
|
63
|
+
deleteInterval = null;
|
|
57
64
|
typewritePlaceholder(fullText);
|
|
58
65
|
}
|
|
59
|
-
}, 15);
|
|
60
|
-
}, 2000);
|
|
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
|
-
|
|
69
|
-
const initialText = placeholderTexts[currentPlaceholderIndex];
|
|
70
|
-
typewritePlaceholder(initialText);
|
|
75
|
+
typewritePlaceholder(placeholderTexts[currentPlaceholderIndex]);
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
rotationInterval = window.setInterval(() => {
|
|
78
|
+
if (destroyed) { clearInterval(rotationInterval!); rotationInterval = null; return; }
|
|
74
79
|
currentPlaceholderIndex = (currentPlaceholderIndex + 1) % placeholderTexts.length;
|
|
75
80
|
updatePlaceholder();
|
|
76
|
-
}, 7000);
|
|
81
|
+
}, 7000);
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
function stopPlaceholderAnimation() {
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
120
|
-
let
|
|
121
|
-
let
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
},
|
|
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
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
|
|
231
|
-
|
|
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={
|
|
234
|
-
|
|
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
|
-
{
|
|
245
|
+
{confirmText}
|
|
237
246
|
</button>
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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}
|