@myrialabs/clopen 0.2.3 → 0.2.5
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/engine/adapters/claude/stream.ts +107 -0
- package/backend/engine/adapters/opencode/message-converter.ts +37 -2
- package/backend/engine/adapters/opencode/stream.ts +81 -1
- package/backend/engine/types.ts +17 -0
- package/backend/git/git-service.ts +2 -1
- package/backend/ws/git/commit-message.ts +108 -0
- package/backend/ws/git/index.ts +3 -1
- package/backend/ws/system/index.ts +7 -1
- package/backend/ws/system/operations.ts +28 -2
- package/backend/ws/user/crud.ts +6 -3
- package/frontend/App.svelte +3 -0
- package/frontend/components/auth/SetupPage.svelte +2 -2
- package/frontend/components/chat/input/ChatInput.svelte +1 -1
- package/frontend/components/chat/message/ChatMessage.svelte +64 -16
- package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
- package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
- package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
- package/frontend/components/common/feedback/UpdateBanner.svelte +17 -6
- package/frontend/components/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +11 -143
- package/frontend/components/git/BranchManager.svelte +143 -155
- package/frontend/components/git/CommitForm.svelte +61 -11
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/SettingsView.svelte +1 -1
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
- package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
- package/frontend/components/settings/git/GitSettings.svelte +392 -0
- package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
- package/frontend/components/settings/model/ModelSettings.svelte +172 -289
- package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/PanelHeader.svelte +1 -3
- package/frontend/components/workspace/WorkspaceLayout.svelte +14 -2
- package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +84 -33
- package/frontend/main.ts +4 -0
- package/frontend/stores/core/files.svelte.ts +15 -1
- package/frontend/stores/features/settings.svelte.ts +13 -2
- package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
- package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
- package/frontend/stores/ui/update.svelte.ts +45 -4
- package/frontend/utils/file-type.ts +68 -0
- package/index.html +1 -0
- package/package.json +1 -1
- package/shared/constants/binary-extensions.ts +40 -0
- package/shared/types/git.ts +15 -0
- package/shared/types/messaging/tool.ts +1 -0
- package/shared/types/stores/settings.ts +12 -0
- package/shared/utils/file-type-detection.ts +9 -1
- package/static/manifest.json +16 -0
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
import type { FileNode } from '$shared/types/filesystem';
|
|
3
3
|
import LoadingSpinner from '../common/feedback/LoadingSpinner.svelte';
|
|
4
4
|
import MonacoEditor from '../common/editor/MonacoEditor.svelte';
|
|
5
|
+
import MediaPreview from '../common/media/MediaPreview.svelte';
|
|
5
6
|
import { themeStore } from '$frontend/stores/ui/theme.svelte';
|
|
6
7
|
import Icon from '$frontend/components/common/display/Icon.svelte';
|
|
7
8
|
import { getFileIcon } from '$frontend/utils/file-icon-mappings';
|
|
8
9
|
import { getFolderIcon } from '$frontend/utils/folder-icon-mappings';
|
|
9
|
-
import {
|
|
10
|
+
import { isImageFile, isSvgFile, isPdfFile, isAudioFile, isVideoFile, isBinaryFile, isBinaryContent, isPreviewableFile, formatFileSize } from '$frontend/utils/file-type';
|
|
11
|
+
import { onMount } from 'svelte';
|
|
10
12
|
import type { IconName } from '$shared/types/ui/icons';
|
|
11
13
|
import type { editor } from 'monaco-editor';
|
|
12
14
|
import { debug } from '$shared/utils/logger';
|
|
13
|
-
import ws from '$frontend/utils/ws';
|
|
14
15
|
|
|
15
16
|
// Interface untuk MonacoEditor component
|
|
16
17
|
interface MonacoEditorComponent {
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
onToggleWordWrap?: () => void;
|
|
39
40
|
externallyChanged?: boolean;
|
|
40
41
|
onForceReload?: () => void;
|
|
42
|
+
isBinary?: boolean;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
const {
|
|
@@ -53,7 +55,8 @@
|
|
|
53
55
|
wordWrap = false,
|
|
54
56
|
onToggleWordWrap,
|
|
55
57
|
externallyChanged = false,
|
|
56
|
-
onForceReload
|
|
58
|
+
onForceReload,
|
|
59
|
+
isBinary = false
|
|
57
60
|
}: Props = $props();
|
|
58
61
|
|
|
59
62
|
// Theme state
|
|
@@ -76,15 +79,9 @@
|
|
|
76
79
|
// Line highlighting state
|
|
77
80
|
let currentDecorations: string[] = $state([]);
|
|
78
81
|
|
|
79
|
-
// Image/binary blob URL state
|
|
80
|
-
let blobUrl = $state<string | null>(null);
|
|
81
|
-
|
|
82
82
|
// SVG view mode
|
|
83
83
|
let svgViewMode = $state<'visual' | 'code'>('visual');
|
|
84
84
|
|
|
85
|
-
// PDF blob URL
|
|
86
|
-
let pdfBlobUrl = $state<string | null>(null);
|
|
87
|
-
|
|
88
85
|
// Keyboard shortcut for save
|
|
89
86
|
onMount(() => {
|
|
90
87
|
function handleKeyDown(e: KeyboardEvent) {
|
|
@@ -103,61 +100,6 @@
|
|
|
103
100
|
};
|
|
104
101
|
});
|
|
105
102
|
|
|
106
|
-
// Cleanup blob URLs on destroy
|
|
107
|
-
onDestroy(() => {
|
|
108
|
-
if (blobUrl) {
|
|
109
|
-
URL.revokeObjectURL(blobUrl);
|
|
110
|
-
}
|
|
111
|
-
if (pdfBlobUrl) {
|
|
112
|
-
URL.revokeObjectURL(pdfBlobUrl);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// Load binary content (images, PDF) via WebSocket when file changes
|
|
117
|
-
$effect(() => {
|
|
118
|
-
if (file && (isImageFile(file.name) || isPdfFile(file.name))) {
|
|
119
|
-
loadBinaryContent();
|
|
120
|
-
} else {
|
|
121
|
-
// Cleanup if not binary
|
|
122
|
-
if (blobUrl) {
|
|
123
|
-
URL.revokeObjectURL(blobUrl);
|
|
124
|
-
blobUrl = null;
|
|
125
|
-
}
|
|
126
|
-
if (pdfBlobUrl) {
|
|
127
|
-
URL.revokeObjectURL(pdfBlobUrl);
|
|
128
|
-
pdfBlobUrl = null;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
async function loadBinaryContent() {
|
|
134
|
-
if (!file) return;
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
const response = await ws.http('files:read-content', { path: file.path });
|
|
138
|
-
|
|
139
|
-
if (response.content) {
|
|
140
|
-
// Decode base64 to binary
|
|
141
|
-
const binaryString = atob(response.content);
|
|
142
|
-
const bytes = new Uint8Array(binaryString.length);
|
|
143
|
-
for (let i = 0; i < binaryString.length; i++) {
|
|
144
|
-
bytes[i] = binaryString.charCodeAt(i);
|
|
145
|
-
}
|
|
146
|
-
const blob = new Blob([bytes], { type: response.contentType || 'application/octet-stream' });
|
|
147
|
-
|
|
148
|
-
if (isPdfFile(file.name)) {
|
|
149
|
-
if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl);
|
|
150
|
-
pdfBlobUrl = URL.createObjectURL(blob);
|
|
151
|
-
} else {
|
|
152
|
-
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
153
|
-
blobUrl = URL.createObjectURL(blob);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
} catch (error) {
|
|
157
|
-
debug.error('file', 'Failed to load binary content:', error);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
103
|
// Reference content for change detection (use savedContent if provided)
|
|
162
104
|
const referenceContent = $derived(savedContentProp !== undefined ? savedContentProp : content);
|
|
163
105
|
|
|
@@ -298,38 +240,6 @@
|
|
|
298
240
|
return getFileIcon(fileName);
|
|
299
241
|
}
|
|
300
242
|
|
|
301
|
-
function isImageFile(fileName: string): boolean {
|
|
302
|
-
const extension = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
|
|
303
|
-
return ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp'].includes(extension);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function isSvgFile(fileName: string): boolean {
|
|
307
|
-
return fileName.toLowerCase().endsWith('.svg');
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function isPdfFile(fileName: string): boolean {
|
|
311
|
-
return fileName.toLowerCase().endsWith('.pdf');
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function isBinaryFile(fileName: string): boolean {
|
|
315
|
-
const extension = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
|
|
316
|
-
return ['.doc', '.docx', '.xls', '.xlsx', '.zip', '.tar', '.gz', '.exe', '.dll',
|
|
317
|
-
'.ppt', '.pptx', '.7z', '.rar', '.bz2', '.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
318
|
-
'.mp3', '.mp4', '.wav', '.avi', '.mkv', '.flv', '.sqlite', '.db'].includes(extension);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function isVisualFile(fileName: string): boolean {
|
|
322
|
-
return isImageFile(fileName) || isSvgFile(fileName) || isPdfFile(fileName);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function formatFileSize(bytes: number): string {
|
|
326
|
-
if (bytes === 0) return '0 B';
|
|
327
|
-
const k = 1024;
|
|
328
|
-
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
329
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
330
|
-
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
331
|
-
}
|
|
332
|
-
|
|
333
243
|
function copyToClipboard() {
|
|
334
244
|
if (editableContent) {
|
|
335
245
|
navigator.clipboard.writeText(editableContent);
|
|
@@ -406,7 +316,7 @@
|
|
|
406
316
|
{/if}
|
|
407
317
|
|
|
408
318
|
<!-- Actions for editable files -->
|
|
409
|
-
{#if file && file.type === 'file' && !isImageFile(file.name) && !isBinaryFile(file.name) && !isPdfFile(file.name) && !(isSvgFile(file.name) && svgViewMode === 'visual')}
|
|
319
|
+
{#if file && file.type === 'file' && !isBinary && !isBinaryContent(content) && !isImageFile(file.name) && !isBinaryFile(file.name) && !isPdfFile(file.name) && !isAudioFile(file.name) && !isVideoFile(file.name) && !(isSvgFile(file.name) && svgViewMode === 'visual')}
|
|
410
320
|
<!-- Word Wrap toggle -->
|
|
411
321
|
{#if onToggleWordWrap}
|
|
412
322
|
<button
|
|
@@ -496,38 +406,9 @@
|
|
|
496
406
|
This is a directory. Select a file to view its content.
|
|
497
407
|
</p>
|
|
498
408
|
</div>
|
|
499
|
-
{:else if isImageFile(file.name)}
|
|
500
|
-
<!-- Image preview -->
|
|
501
|
-
<div class="flex items-center justify-center h-full p-4 overflow-hidden">
|
|
502
|
-
{#if blobUrl}
|
|
503
|
-
<img
|
|
504
|
-
src={blobUrl}
|
|
505
|
-
alt={file.name}
|
|
506
|
-
class="max-w-full max-h-full object-contain"
|
|
507
|
-
/>
|
|
508
|
-
{:else}
|
|
509
|
-
<LoadingSpinner size="lg" />
|
|
510
|
-
{/if}
|
|
511
|
-
</div>
|
|
512
409
|
{:else if isSvgFile(file.name)}
|
|
513
|
-
<!-- SVG: visual or code view -->
|
|
514
410
|
{#if svgViewMode === 'visual'}
|
|
515
|
-
<
|
|
516
|
-
{#if blobUrl}
|
|
517
|
-
<img
|
|
518
|
-
src={blobUrl}
|
|
519
|
-
alt={file.name}
|
|
520
|
-
class="max-w-full max-h-full object-contain"
|
|
521
|
-
/>
|
|
522
|
-
{:else if content}
|
|
523
|
-
<!-- Render SVG from content directly -->
|
|
524
|
-
<div class="max-w-full max-h-full flex items-center justify-center">
|
|
525
|
-
{@html content}
|
|
526
|
-
</div>
|
|
527
|
-
{:else}
|
|
528
|
-
<LoadingSpinner size="lg" />
|
|
529
|
-
{/if}
|
|
530
|
-
</div>
|
|
411
|
+
<MediaPreview fileName={file.name} filePath={file.path} svgContent={content} />
|
|
531
412
|
{:else}
|
|
532
413
|
<!-- SVG code view (editable) -->
|
|
533
414
|
<div class="h-full flex flex-col bg-slate-50 dark:bg-slate-950">
|
|
@@ -561,22 +442,9 @@
|
|
|
561
442
|
{/if}
|
|
562
443
|
</div>
|
|
563
444
|
{/if}
|
|
564
|
-
{:else if
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
{#if pdfBlobUrl}
|
|
568
|
-
<iframe
|
|
569
|
-
src={pdfBlobUrl}
|
|
570
|
-
title={file.name}
|
|
571
|
-
class="w-full h-full border-0"
|
|
572
|
-
></iframe>
|
|
573
|
-
{:else}
|
|
574
|
-
<div class="flex items-center justify-center h-full">
|
|
575
|
-
<LoadingSpinner size="lg" />
|
|
576
|
-
</div>
|
|
577
|
-
{/if}
|
|
578
|
-
</div>
|
|
579
|
-
{:else if isBinaryFile(file.name)}
|
|
445
|
+
{:else if isPreviewableFile(file.name)}
|
|
446
|
+
<MediaPreview fileName={file.name} filePath={file.path} />
|
|
447
|
+
{:else if isBinary || isBinaryFile(file.name) || isBinaryContent(content)}
|
|
580
448
|
<div class="flex flex-col items-center justify-center h-full p-8">
|
|
581
449
|
<Icon name="lucide:file-text" class="w-16 h-16 text-slate-400 mb-4" />
|
|
582
450
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
let showAddRemoteForm = $state(false);
|
|
36
36
|
let newRemoteName = $state('origin');
|
|
37
37
|
let newRemoteUrl = $state('');
|
|
38
|
-
let showRemoteSection = $state(false);
|
|
39
38
|
|
|
40
39
|
// Confirm dialog
|
|
41
40
|
let showConfirmDialog = $state(false);
|
|
@@ -168,49 +167,6 @@
|
|
|
168
167
|
{/snippet}
|
|
169
168
|
|
|
170
169
|
{#snippet children()}
|
|
171
|
-
<!-- Create branch form -->
|
|
172
|
-
{#if showCreateForm}
|
|
173
|
-
<div class="mb-4 p-3 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg space-y-2">
|
|
174
|
-
<input
|
|
175
|
-
type="text"
|
|
176
|
-
bind:value={newBranchName}
|
|
177
|
-
placeholder="New branch name..."
|
|
178
|
-
class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40"
|
|
179
|
-
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
|
|
180
|
-
autofocus
|
|
181
|
-
/>
|
|
182
|
-
<div class="flex gap-2">
|
|
183
|
-
<button
|
|
184
|
-
type="button"
|
|
185
|
-
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer border-none
|
|
186
|
-
{newBranchName.trim()
|
|
187
|
-
? 'bg-violet-600 text-white hover:bg-violet-700'
|
|
188
|
-
: 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'}"
|
|
189
|
-
onclick={handleCreate}
|
|
190
|
-
disabled={!newBranchName.trim()}
|
|
191
|
-
>
|
|
192
|
-
Create Branch
|
|
193
|
-
</button>
|
|
194
|
-
<button
|
|
195
|
-
type="button"
|
|
196
|
-
class="px-3 py-2 text-sm font-medium bg-transparent border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
|
|
197
|
-
onclick={() => { showCreateForm = false; newBranchName = ''; }}
|
|
198
|
-
>
|
|
199
|
-
Cancel
|
|
200
|
-
</button>
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
{:else}
|
|
204
|
-
<button
|
|
205
|
-
type="button"
|
|
206
|
-
class="flex items-center justify-center gap-2 w-full mb-4 py-2.5 px-3 border border-dashed border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 hover:text-violet-600 hover:border-violet-400 transition-colors cursor-pointer bg-transparent"
|
|
207
|
-
onclick={() => showCreateForm = true}
|
|
208
|
-
>
|
|
209
|
-
<Icon name="lucide:plus" class="w-4 h-4" />
|
|
210
|
-
<span>Create New Branch</span>
|
|
211
|
-
</button>
|
|
212
|
-
{/if}
|
|
213
|
-
|
|
214
170
|
<!-- Search -->
|
|
215
171
|
<div class="mb-4">
|
|
216
172
|
<div class="flex items-center gap-2 py-2.5 px-3.5 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-lg">
|
|
@@ -242,7 +198,7 @@
|
|
|
242
198
|
{activeTab === 'local'
|
|
243
199
|
? 'bg-violet-500/10 text-violet-600'
|
|
244
200
|
: 'bg-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'}"
|
|
245
|
-
onclick={() => activeTab = 'local'}
|
|
201
|
+
onclick={() => { activeTab = 'local'; showCreateForm = false; newBranchName = ''; }}
|
|
246
202
|
>
|
|
247
203
|
Local ({filteredLocal.length})
|
|
248
204
|
</button>
|
|
@@ -261,6 +217,49 @@
|
|
|
261
217
|
<!-- Branch list -->
|
|
262
218
|
<div class="space-y-1.5 max-h-80 overflow-y-auto">
|
|
263
219
|
{#if activeTab === 'local'}
|
|
220
|
+
<!-- Create branch form (Local tab only) -->
|
|
221
|
+
{#if showCreateForm}
|
|
222
|
+
<div class="mb-2 p-3 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg space-y-2">
|
|
223
|
+
<input
|
|
224
|
+
type="text"
|
|
225
|
+
bind:value={newBranchName}
|
|
226
|
+
placeholder="New branch name..."
|
|
227
|
+
class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40"
|
|
228
|
+
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
|
|
229
|
+
autofocus
|
|
230
|
+
/>
|
|
231
|
+
<div class="flex gap-2">
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer border-none
|
|
235
|
+
{newBranchName.trim()
|
|
236
|
+
? 'bg-violet-600 text-white hover:bg-violet-700'
|
|
237
|
+
: 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'}"
|
|
238
|
+
onclick={handleCreate}
|
|
239
|
+
disabled={!newBranchName.trim()}
|
|
240
|
+
>
|
|
241
|
+
Create Branch
|
|
242
|
+
</button>
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
class="px-3 py-2 text-sm font-medium bg-transparent border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
|
|
246
|
+
onclick={() => { showCreateForm = false; newBranchName = ''; }}
|
|
247
|
+
>
|
|
248
|
+
Cancel
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
{:else}
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
class="flex items-center justify-center gap-2 w-full mb-2 py-2.5 px-3 border border-dashed border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 hover:text-violet-600 hover:border-violet-400 transition-colors cursor-pointer bg-transparent"
|
|
256
|
+
onclick={() => showCreateForm = true}
|
|
257
|
+
>
|
|
258
|
+
<Icon name="lucide:plus" class="w-4 h-4" />
|
|
259
|
+
<span>Create New Branch</span>
|
|
260
|
+
</button>
|
|
261
|
+
{/if}
|
|
262
|
+
|
|
264
263
|
{#each filteredLocal as branch (branch.name)}
|
|
265
264
|
<div
|
|
266
265
|
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors
|
|
@@ -320,129 +319,118 @@
|
|
|
320
319
|
</div>
|
|
321
320
|
{/each}
|
|
322
321
|
{:else if activeTab === 'remote'}
|
|
323
|
-
{#
|
|
324
|
-
<div
|
|
325
|
-
class="
|
|
326
|
-
>
|
|
327
|
-
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
|
328
|
-
<Icon name="lucide:cloud" class="w-4 h-4 text-slate-400 shrink-0" />
|
|
329
|
-
<span class="text-sm text-slate-900 dark:text-slate-100 truncate">{branch.name}</span>
|
|
330
|
-
</div>
|
|
331
|
-
|
|
332
|
-
<div class="items-center gap-1 shrink-0 hidden group-hover:flex">
|
|
333
|
-
<button
|
|
334
|
-
type="button"
|
|
335
|
-
class="flex items-center justify-center w-8 h-8 rounded-lg text-slate-500 hover:bg-violet-500/10 hover:text-violet-600 transition-colors cursor-pointer bg-transparent border-none"
|
|
336
|
-
onclick={() => handleSwitchRemote(branch.name)}
|
|
337
|
-
title="Checkout remote branch"
|
|
338
|
-
>
|
|
339
|
-
<Icon name="lucide:arrow-right" class="w-4 h-4" />
|
|
340
|
-
</button>
|
|
341
|
-
</div>
|
|
322
|
+
{#if isLoadingRemotes}
|
|
323
|
+
<div class="flex items-center justify-center py-8">
|
|
324
|
+
<div class="w-4 h-4 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
|
|
342
325
|
</div>
|
|
343
|
-
{:else}
|
|
326
|
+
{:else if remotes.length === 0}
|
|
344
327
|
<div class="flex flex-col items-center gap-2 py-8 text-slate-500 dark:text-slate-400 text-sm">
|
|
345
|
-
<Icon name="lucide:
|
|
346
|
-
<p class="font-medium">No remote
|
|
328
|
+
<Icon name="lucide:server-off" class="w-10 h-10 opacity-40" />
|
|
329
|
+
<p class="font-medium">No remote connections</p>
|
|
330
|
+
<p class="text-xs text-center opacity-70">Add a remote below to track remote branches</p>
|
|
347
331
|
</div>
|
|
348
|
-
{
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
>
|
|
359
|
-
<Icon name={showRemoteSection ? 'lucide:chevron-down' : 'lucide:chevron-right'} class="w-3.5 h-3.5 text-slate-400" />
|
|
360
|
-
<Icon name="lucide:server" class="w-3.5 h-3.5 text-slate-500" />
|
|
361
|
-
<span class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
|
362
|
-
Remote Servers ({remotes.length})
|
|
363
|
-
</span>
|
|
364
|
-
</button>
|
|
365
|
-
|
|
366
|
-
{#if showRemoteSection}
|
|
367
|
-
<div class="mt-2 space-y-2">
|
|
368
|
-
{#if isLoadingRemotes}
|
|
369
|
-
<div class="flex items-center justify-center py-4">
|
|
370
|
-
<div class="w-4 h-4 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
|
|
371
|
-
</div>
|
|
372
|
-
{:else}
|
|
373
|
-
<!-- Existing remotes -->
|
|
374
|
-
{#each remotes as remote (remote.name)}
|
|
375
|
-
<div class="group flex items-center gap-2.5 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/30">
|
|
376
|
-
<div class="flex items-center justify-center w-6 h-6 rounded-md bg-slate-100 dark:bg-slate-800">
|
|
377
|
-
<Icon name="lucide:globe" class="w-3.5 h-3.5 text-slate-500" />
|
|
378
|
-
</div>
|
|
379
|
-
<div class="flex-1 min-w-0">
|
|
380
|
-
<div class="text-sm font-medium text-slate-900 dark:text-slate-100">{remote.name}</div>
|
|
381
|
-
<div class="text-xs text-slate-500 dark:text-slate-400 truncate font-mono">{remote.fetchUrl}</div>
|
|
382
|
-
</div>
|
|
383
|
-
<button
|
|
384
|
-
type="button"
|
|
385
|
-
class="flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer bg-transparent border-none shrink-0 opacity-0 group-hover:opacity-100"
|
|
386
|
-
onclick={() => handleRemoveRemote(remote.name)}
|
|
387
|
-
title="Disconnect remote"
|
|
388
|
-
>
|
|
389
|
-
<Icon name="lucide:unlink" class="w-3.5 h-3.5" />
|
|
390
|
-
</button>
|
|
391
|
-
</div>
|
|
392
|
-
{/each}
|
|
393
|
-
|
|
394
|
-
<!-- Add remote form -->
|
|
395
|
-
{#if showAddRemoteForm}
|
|
396
|
-
<div class="p-3 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg space-y-2">
|
|
397
|
-
<input
|
|
398
|
-
type="text"
|
|
399
|
-
bind:value={newRemoteName}
|
|
400
|
-
placeholder="Name (e.g. origin)"
|
|
401
|
-
class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40"
|
|
402
|
-
/>
|
|
403
|
-
<input
|
|
404
|
-
type="text"
|
|
405
|
-
bind:value={newRemoteUrl}
|
|
406
|
-
placeholder="URL (e.g. https://github.com/user/repo.git)"
|
|
407
|
-
class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40 font-mono"
|
|
408
|
-
onkeydown={(e) => e.key === 'Enter' && handleAddRemote()}
|
|
409
|
-
autofocus
|
|
410
|
-
/>
|
|
411
|
-
<div class="flex gap-2">
|
|
412
|
-
<button
|
|
413
|
-
type="button"
|
|
414
|
-
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer border-none
|
|
415
|
-
{newRemoteName.trim() && newRemoteUrl.trim()
|
|
416
|
-
? 'bg-violet-600 text-white hover:bg-violet-700'
|
|
417
|
-
: 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'}"
|
|
418
|
-
onclick={handleAddRemote}
|
|
419
|
-
disabled={!newRemoteName.trim() || !newRemoteUrl.trim()}
|
|
420
|
-
>
|
|
421
|
-
Connect
|
|
422
|
-
</button>
|
|
332
|
+
{:else}
|
|
333
|
+
{#each remotes as remote (remote.name)}
|
|
334
|
+
{@const remoteBranches = filteredRemote.filter(b => b.name.startsWith(remote.name + '/'))}
|
|
335
|
+
{#if !searchQuery || remoteBranches.length > 0}
|
|
336
|
+
<div class="space-y-1">
|
|
337
|
+
<!-- Remote group header -->
|
|
338
|
+
<div class="group flex items-center gap-2 px-2 py-1.5 rounded-lg">
|
|
339
|
+
<Icon name="lucide:server" class="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
|
340
|
+
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300">{remote.name}</span>
|
|
341
|
+
<span class="text-xs text-slate-400 dark:text-slate-500 font-mono truncate flex-1">{remote.fetchUrl}</span>
|
|
423
342
|
<button
|
|
424
343
|
type="button"
|
|
425
|
-
class="
|
|
426
|
-
onclick={() =>
|
|
344
|
+
class="flex items-center justify-center w-6 h-6 rounded-md text-slate-400 hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer bg-transparent border-none shrink-0 opacity-0 group-hover:opacity-100"
|
|
345
|
+
onclick={() => handleRemoveRemote(remote.name)}
|
|
346
|
+
title="Disconnect remote"
|
|
427
347
|
>
|
|
428
|
-
|
|
348
|
+
<Icon name="lucide:unlink" class="w-3.5 h-3.5" />
|
|
429
349
|
</button>
|
|
430
350
|
</div>
|
|
351
|
+
|
|
352
|
+
<!-- Branches under this remote -->
|
|
353
|
+
{#if remoteBranches.length > 0}
|
|
354
|
+
<div class="ml-5 space-y-1">
|
|
355
|
+
{#each remoteBranches as branch (branch.name)}
|
|
356
|
+
<div class="group flex items-center gap-2.5 px-3 py-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800/50 transition-colors border border-slate-200 dark:border-slate-700">
|
|
357
|
+
<Icon name="lucide:git-branch" class="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
|
358
|
+
<span class="text-sm text-slate-900 dark:text-slate-100 truncate flex-1">
|
|
359
|
+
{branch.name.substring(remote.name.length + 1)}
|
|
360
|
+
</span>
|
|
361
|
+
<button
|
|
362
|
+
type="button"
|
|
363
|
+
class="flex items-center justify-center w-7 h-7 rounded-lg text-slate-400 hover:bg-violet-500/10 hover:text-violet-600 transition-colors cursor-pointer bg-transparent border-none shrink-0 opacity-0 group-hover:opacity-100"
|
|
364
|
+
onclick={() => handleSwitchRemote(branch.name)}
|
|
365
|
+
title="Checkout remote branch locally"
|
|
366
|
+
>
|
|
367
|
+
<Icon name="lucide:arrow-right" class="w-4 h-4" />
|
|
368
|
+
</button>
|
|
369
|
+
</div>
|
|
370
|
+
{/each}
|
|
371
|
+
</div>
|
|
372
|
+
{:else if !searchQuery}
|
|
373
|
+
<p class="ml-7 text-xs text-slate-400 dark:text-slate-500 py-1">No branches — try Fetch</p>
|
|
374
|
+
{/if}
|
|
431
375
|
</div>
|
|
432
|
-
{
|
|
376
|
+
{/if}
|
|
377
|
+
{/each}
|
|
378
|
+
{/if}
|
|
379
|
+
{/if}
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<!-- Add remote (only shown in Remote tab) -->
|
|
383
|
+
{#if activeTab === 'remote'}
|
|
384
|
+
<div class="mt-3 pt-3 border-t border-slate-200 dark:border-slate-700">
|
|
385
|
+
{#if showAddRemoteForm}
|
|
386
|
+
<div class="p-3 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg space-y-2">
|
|
387
|
+
<input
|
|
388
|
+
type="text"
|
|
389
|
+
bind:value={newRemoteName}
|
|
390
|
+
placeholder="Name (e.g. origin)"
|
|
391
|
+
class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40"
|
|
392
|
+
/>
|
|
393
|
+
<input
|
|
394
|
+
type="text"
|
|
395
|
+
bind:value={newRemoteUrl}
|
|
396
|
+
placeholder="URL (e.g. https://github.com/user/repo.git)"
|
|
397
|
+
class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40 font-mono"
|
|
398
|
+
onkeydown={(e) => e.key === 'Enter' && handleAddRemote()}
|
|
399
|
+
autofocus
|
|
400
|
+
/>
|
|
401
|
+
<div class="flex gap-2">
|
|
433
402
|
<button
|
|
434
403
|
type="button"
|
|
435
|
-
class="flex
|
|
436
|
-
|
|
404
|
+
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer border-none
|
|
405
|
+
{newRemoteName.trim() && newRemoteUrl.trim()
|
|
406
|
+
? 'bg-violet-600 text-white hover:bg-violet-700'
|
|
407
|
+
: 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'}"
|
|
408
|
+
onclick={handleAddRemote}
|
|
409
|
+
disabled={!newRemoteName.trim() || !newRemoteUrl.trim()}
|
|
437
410
|
>
|
|
438
|
-
|
|
439
|
-
<span>Add Remote Server</span>
|
|
411
|
+
Connect
|
|
440
412
|
</button>
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
class="px-3 py-2 text-sm font-medium bg-transparent border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
|
|
416
|
+
onclick={() => { showAddRemoteForm = false; newRemoteName = 'origin'; newRemoteUrl = ''; }}
|
|
417
|
+
>
|
|
418
|
+
Cancel
|
|
419
|
+
</button>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
{:else}
|
|
423
|
+
<button
|
|
424
|
+
type="button"
|
|
425
|
+
class="flex items-center gap-2 w-full px-3 py-2 text-xs text-slate-500 hover:text-violet-600 hover:bg-violet-500/5 rounded-lg transition-colors cursor-pointer bg-transparent border-none"
|
|
426
|
+
onclick={() => showAddRemoteForm = true}
|
|
427
|
+
>
|
|
428
|
+
<Icon name="lucide:plus" class="w-3.5 h-3.5" />
|
|
429
|
+
<span>Add Remote</span>
|
|
430
|
+
</button>
|
|
431
|
+
{/if}
|
|
432
|
+
</div>
|
|
433
|
+
{/if}
|
|
446
434
|
{/snippet}
|
|
447
435
|
</Modal>
|
|
448
436
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
2
3
|
import Icon from '$frontend/components/common/display/Icon.svelte';
|
|
4
|
+
import { settings } from '$frontend/stores/features/settings.svelte';
|
|
5
|
+
import { projectState } from '$frontend/stores/core/projects.svelte';
|
|
6
|
+
import { showError } from '$frontend/stores/ui/notification.svelte';
|
|
7
|
+
import ws from '$frontend/utils/ws';
|
|
3
8
|
|
|
4
9
|
interface Props {
|
|
5
10
|
stagedCount: number;
|
|
@@ -11,6 +16,7 @@
|
|
|
11
16
|
|
|
12
17
|
let commitMessage = $state('');
|
|
13
18
|
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
|
19
|
+
let isGenerating = $state(false);
|
|
14
20
|
|
|
15
21
|
function handleCommit() {
|
|
16
22
|
if (!commitMessage.trim() || stagedCount === 0) return;
|
|
@@ -42,21 +48,65 @@
|
|
|
42
48
|
function handleInput() {
|
|
43
49
|
autoResize();
|
|
44
50
|
}
|
|
51
|
+
|
|
52
|
+
async function generateCommitMessage() {
|
|
53
|
+
const projectId = projectState.currentProject?.id;
|
|
54
|
+
if (!projectId || stagedCount === 0 || isGenerating) return;
|
|
55
|
+
|
|
56
|
+
isGenerating = true;
|
|
57
|
+
try {
|
|
58
|
+
const { useCustomModel, engine, model, format } = settings.commitGenerator;
|
|
59
|
+
const resolvedEngine = useCustomModel ? engine : settings.selectedEngine;
|
|
60
|
+
const resolvedModel = useCustomModel ? model : settings.selectedModel;
|
|
61
|
+
const result = await ws.http('git:generate-commit-message', {
|
|
62
|
+
projectId,
|
|
63
|
+
engine: resolvedEngine,
|
|
64
|
+
model: resolvedModel,
|
|
65
|
+
format
|
|
66
|
+
});
|
|
67
|
+
commitMessage = result.message;
|
|
68
|
+
await tick();
|
|
69
|
+
autoResize();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
showError('Generate Failed', err instanceof Error ? err.message : 'Failed to generate commit message');
|
|
72
|
+
} finally {
|
|
73
|
+
isGenerating = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
45
76
|
</script>
|
|
46
77
|
|
|
47
78
|
<div class="px-2 py-2">
|
|
48
79
|
<div class="flex flex-col gap-1.5">
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
<div class="relative">
|
|
81
|
+
<textarea
|
|
82
|
+
bind:this={textareaEl}
|
|
83
|
+
bind:value={commitMessage}
|
|
84
|
+
placeholder="Commit message..."
|
|
85
|
+
class="w-full px-2.5 py-2 pr-8 text-sm bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-600 resize-none outline-none focus:ring-1 focus:ring-violet-500 transition-colors"
|
|
86
|
+
rows="1"
|
|
87
|
+
style="overflow-y: hidden;"
|
|
88
|
+
onkeydown={handleKeydown}
|
|
89
|
+
oninput={handleInput}
|
|
90
|
+
disabled={isCommitting || isGenerating}
|
|
91
|
+
></textarea>
|
|
92
|
+
<!-- AI generate button -->
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
class="absolute top-1.5 right-1.5 flex items-center justify-center w-6 h-6 rounded transition-all duration-150
|
|
96
|
+
{stagedCount > 0 && !isGenerating && !isCommitting
|
|
97
|
+
? 'text-slate-400 hover:text-violet-500 hover:bg-violet-500/10 cursor-pointer'
|
|
98
|
+
: 'text-slate-300 dark:text-slate-700 cursor-not-allowed'}"
|
|
99
|
+
onclick={generateCommitMessage}
|
|
100
|
+
disabled={stagedCount === 0 || isGenerating || isCommitting}
|
|
101
|
+
title="Generate commit message with AI"
|
|
102
|
+
>
|
|
103
|
+
{#if isGenerating}
|
|
104
|
+
<div class="w-3.5 h-3.5 border-2 border-violet-400/30 border-t-violet-500 rounded-full animate-spin"></div>
|
|
105
|
+
{:else}
|
|
106
|
+
<Icon name="lucide:sparkles" class="w-3.5 h-3.5" />
|
|
107
|
+
{/if}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
60
110
|
<button
|
|
61
111
|
type="button"
|
|
62
112
|
class="flex items-center justify-center gap-1.5 w-full py-1.5 px-3 rounded-md text-xs font-medium transition-all duration-150
|