@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.
Files changed (53) hide show
  1. package/backend/engine/adapters/claude/stream.ts +107 -0
  2. package/backend/engine/adapters/opencode/message-converter.ts +37 -2
  3. package/backend/engine/adapters/opencode/stream.ts +81 -1
  4. package/backend/engine/types.ts +17 -0
  5. package/backend/git/git-service.ts +2 -1
  6. package/backend/ws/git/commit-message.ts +108 -0
  7. package/backend/ws/git/index.ts +3 -1
  8. package/backend/ws/system/index.ts +7 -1
  9. package/backend/ws/system/operations.ts +28 -2
  10. package/backend/ws/user/crud.ts +6 -3
  11. package/frontend/App.svelte +3 -0
  12. package/frontend/components/auth/SetupPage.svelte +2 -2
  13. package/frontend/components/chat/input/ChatInput.svelte +1 -1
  14. package/frontend/components/chat/message/ChatMessage.svelte +64 -16
  15. package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
  16. package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
  17. package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
  18. package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
  19. package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
  20. package/frontend/components/common/feedback/UpdateBanner.svelte +17 -6
  21. package/frontend/components/common/media/MediaPreview.svelte +187 -0
  22. package/frontend/components/files/FileViewer.svelte +11 -143
  23. package/frontend/components/git/BranchManager.svelte +143 -155
  24. package/frontend/components/git/CommitForm.svelte +61 -11
  25. package/frontend/components/git/DiffViewer.svelte +50 -130
  26. package/frontend/components/git/FileChangeItem.svelte +22 -0
  27. package/frontend/components/settings/SettingsModal.svelte +1 -1
  28. package/frontend/components/settings/SettingsView.svelte +1 -1
  29. package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
  30. package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
  31. package/frontend/components/settings/git/GitSettings.svelte +392 -0
  32. package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
  33. package/frontend/components/settings/model/ModelSettings.svelte +172 -289
  34. package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
  35. package/frontend/components/workspace/PanelHeader.svelte +1 -3
  36. package/frontend/components/workspace/WorkspaceLayout.svelte +14 -2
  37. package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
  38. package/frontend/components/workspace/panels/GitPanel.svelte +84 -33
  39. package/frontend/main.ts +4 -0
  40. package/frontend/stores/core/files.svelte.ts +15 -1
  41. package/frontend/stores/features/settings.svelte.ts +13 -2
  42. package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
  43. package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
  44. package/frontend/stores/ui/update.svelte.ts +45 -4
  45. package/frontend/utils/file-type.ts +68 -0
  46. package/index.html +1 -0
  47. package/package.json +1 -1
  48. package/shared/constants/binary-extensions.ts +40 -0
  49. package/shared/types/git.ts +15 -0
  50. package/shared/types/messaging/tool.ts +1 -0
  51. package/shared/types/stores/settings.ts +12 -0
  52. package/shared/utils/file-type-detection.ts +9 -1
  53. 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 { onMount, onDestroy } from 'svelte';
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
- <div class="flex items-center justify-center h-full p-4 overflow-auto bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%3E%3Crect%20width%3D%2220%22%20height%3D%2220%22%20fill%3D%22%23f0f0f0%22%2F%3E%3Crect%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Crect%20x%3D%2210%22%20y%3D%2210%22%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23e0e0e0%22%2F%3E%3C%2Fsvg%3E')]">
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 isPdfFile(file.name)}
565
- <!-- PDF preview -->
566
- <div class="h-full w-full">
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
- {#each filteredRemote as branch (branch.name)}
324
- <div
325
- class="group flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800/50 transition-colors border border-slate-200 dark:border-slate-700"
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:search-x" class="w-10 h-10 opacity-40" />
346
- <p class="font-medium">No remote branches found</p>
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
- {/each}
349
- {/if}
350
- </div>
351
-
352
- <!-- Remote Connections Section -->
353
- <div class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
354
- <button
355
- type="button"
356
- class="flex items-center gap-2 w-full text-left bg-transparent border-none cursor-pointer px-0 py-1"
357
- onclick={() => showRemoteSection = !showRemoteSection}
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="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"
426
- onclick={() => { showAddRemoteForm = false; newRemoteName = 'origin'; newRemoteUrl = ''; }}
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
- Cancel
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
- {:else}
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 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"
436
- onclick={() => { showAddRemoteForm = true; showRemoteSection = true; }}
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
- <Icon name="lucide:plus" class="w-3.5 h-3.5" />
439
- <span>Add Remote Server</span>
411
+ Connect
440
412
  </button>
441
- {/if}
442
- {/if}
443
- </div>
444
- {/if}
445
- </div>
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
- <textarea
50
- bind:this={textareaEl}
51
- bind:value={commitMessage}
52
- placeholder="Commit message..."
53
- class="w-full px-2.5 py-2 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"
54
- rows="1"
55
- style="overflow-y: hidden;"
56
- onkeydown={handleKeydown}
57
- oninput={handleInput}
58
- disabled={isCommitting}
59
- ></textarea>
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