@myrialabs/clopen 0.2.4 → 0.2.6

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 (43) hide show
  1. package/backend/chat/stream-manager.ts +136 -10
  2. package/backend/database/queries/session-queries.ts +9 -0
  3. package/backend/engine/adapters/claude/error-handler.ts +7 -2
  4. package/backend/engine/adapters/claude/stream.ts +21 -3
  5. package/backend/engine/adapters/opencode/message-converter.ts +37 -2
  6. package/backend/index.ts +25 -3
  7. package/backend/preview/browser/browser-preview-service.ts +16 -17
  8. package/backend/preview/browser/browser-video-capture.ts +199 -156
  9. package/backend/preview/browser/scripts/video-stream.ts +3 -5
  10. package/backend/snapshot/helpers.ts +15 -2
  11. package/backend/ws/snapshot/restore.ts +43 -2
  12. package/backend/ws/user/crud.ts +6 -3
  13. package/frontend/components/chat/input/ChatInput.svelte +6 -1
  14. package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
  15. package/frontend/components/chat/message/MessageBubble.svelte +22 -1
  16. package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
  17. package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
  18. package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
  19. package/frontend/components/common/media/MediaPreview.svelte +187 -0
  20. package/frontend/components/files/FileViewer.svelte +23 -144
  21. package/frontend/components/git/DiffViewer.svelte +50 -130
  22. package/frontend/components/git/FileChangeItem.svelte +22 -0
  23. package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
  24. package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
  25. package/frontend/components/preview/browser/components/Container.svelte +2 -1
  26. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
  27. package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
  28. package/frontend/components/terminal/TerminalTabs.svelte +1 -2
  29. package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
  30. package/frontend/components/workspace/WorkspaceLayout.svelte +3 -1
  31. package/frontend/components/workspace/panels/FilesPanel.svelte +77 -1
  32. package/frontend/components/workspace/panels/GitPanel.svelte +72 -28
  33. package/frontend/services/chat/chat.service.ts +6 -1
  34. package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
  35. package/frontend/stores/core/files.svelte.ts +15 -1
  36. package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
  37. package/frontend/utils/file-type.ts +68 -0
  38. package/index.html +1 -0
  39. package/package.json +1 -1
  40. package/shared/constants/binary-extensions.ts +40 -0
  41. package/shared/types/messaging/tool.ts +1 -0
  42. package/shared/utils/file-type-detection.ts +9 -1
  43. 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,8 @@
38
39
  onToggleWordWrap?: () => void;
39
40
  externallyChanged?: boolean;
40
41
  onForceReload?: () => void;
42
+ isBinary?: boolean;
43
+ projectPath?: string;
41
44
  }
42
45
 
43
46
  const {
@@ -53,9 +56,20 @@
53
56
  wordWrap = false,
54
57
  onToggleWordWrap,
55
58
  externallyChanged = false,
56
- onForceReload
59
+ onForceReload,
60
+ isBinary = false,
61
+ projectPath = ''
57
62
  }: Props = $props();
58
63
 
64
+ // Relative path for display
65
+ const displayPath = $derived.by(() => {
66
+ if (!file) return '';
67
+ if (projectPath && file.path.startsWith(projectPath)) {
68
+ return file.path.slice(projectPath.length).replace(/^[/\\]/, '');
69
+ }
70
+ return file.path;
71
+ });
72
+
59
73
  // Theme state
60
74
  const isDark = $derived(themeStore.isDark);
61
75
  const monacoTheme = $derived(isDark ? 'vs-dark' : 'vs-light');
@@ -76,15 +90,9 @@
76
90
  // Line highlighting state
77
91
  let currentDecorations: string[] = $state([]);
78
92
 
79
- // Image/binary blob URL state
80
- let blobUrl = $state<string | null>(null);
81
-
82
93
  // SVG view mode
83
94
  let svgViewMode = $state<'visual' | 'code'>('visual');
84
95
 
85
- // PDF blob URL
86
- let pdfBlobUrl = $state<string | null>(null);
87
-
88
96
  // Keyboard shortcut for save
89
97
  onMount(() => {
90
98
  function handleKeyDown(e: KeyboardEvent) {
@@ -103,61 +111,6 @@
103
111
  };
104
112
  });
105
113
 
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
114
  // Reference content for change detection (use savedContent if provided)
162
115
  const referenceContent = $derived(savedContentProp !== undefined ? savedContentProp : content);
163
116
 
@@ -298,38 +251,6 @@
298
251
  return getFileIcon(fileName);
299
252
  }
300
253
 
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
254
  function copyToClipboard() {
334
255
  if (editableContent) {
335
256
  navigator.clipboard.writeText(editableContent);
@@ -363,7 +284,7 @@
363
284
  {file.name}
364
285
  </h3>
365
286
  <p class="text-xs text-slate-600 dark:text-slate-400 truncate mt-0.5">
366
- <span class="hidden sm:inline">{file.path} • </span> {formatFileSize(file.size || 0)}
287
+ <span class="hidden sm:inline">{displayPath} • </span> {formatFileSize(file.size || 0)}
367
288
  </p>
368
289
  </div>
369
290
  </div>
@@ -406,7 +327,7 @@
406
327
  {/if}
407
328
 
408
329
  <!-- Actions for editable files -->
409
- {#if file && file.type === 'file' && !isImageFile(file.name) && !isBinaryFile(file.name) && !isPdfFile(file.name) && !(isSvgFile(file.name) && svgViewMode === 'visual')}
330
+ {#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
331
  <!-- Word Wrap toggle -->
411
332
  {#if onToggleWordWrap}
412
333
  <button
@@ -496,38 +417,9 @@
496
417
  This is a directory. Select a file to view its content.
497
418
  </p>
498
419
  </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
420
  {:else if isSvgFile(file.name)}
513
- <!-- SVG: visual or code view -->
514
421
  {#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>
422
+ <MediaPreview fileName={file.name} filePath={file.path} svgContent={content} />
531
423
  {:else}
532
424
  <!-- SVG code view (editable) -->
533
425
  <div class="h-full flex flex-col bg-slate-50 dark:bg-slate-950">
@@ -561,22 +453,9 @@
561
453
  {/if}
562
454
  </div>
563
455
  {/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)}
456
+ {:else if isPreviewableFile(file.name)}
457
+ <MediaPreview fileName={file.name} filePath={file.path} />
458
+ {:else if isBinary || isBinaryFile(file.name) || isBinaryContent(content)}
580
459
  <div class="flex flex-col items-center justify-center h-full p-8">
581
460
  <Icon name="lucide:file-text" class="w-16 h-16 text-slate-400 mb-4" />
582
461
  <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">
2
- import { onMount, onDestroy, untrack } from 'svelte';
2
+ import { onDestroy, untrack } from 'svelte';
3
3
  import Icon from '$frontend/components/common/display/Icon.svelte';
4
+ import MediaPreview from '$frontend/components/common/media/MediaPreview.svelte';
4
5
  import { getFileIcon } from '$frontend/utils/file-icon-mappings';
6
+ import { isPreviewableFile } from '$frontend/utils/file-type';
5
7
  import { getGitStatusBadgeLabel, getGitStatusBadgeColor } from '$frontend/utils/git-status';
6
8
  import { themeStore } from '$frontend/stores/ui/theme.svelte';
7
9
  import { projectState } from '$frontend/stores/core/projects.svelte';
@@ -11,7 +13,8 @@
11
13
  import type { GitFileDiff } from '$shared/types/git';
12
14
  import type { IconName } from '$shared/types/ui/icons';
13
15
  import { debug } from '$shared/utils/logger';
14
- import ws from '$frontend/utils/ws';
16
+ import { requestRevealFile } from '$frontend/stores/core/files.svelte';
17
+ import { getVisiblePanels, workspaceState } from '$frontend/stores/ui/workspace.svelte';
15
18
 
16
19
  interface Props {
17
20
  diff: GitFileDiff | null;
@@ -32,91 +35,21 @@
32
35
  let monacoInstance: typeof import('monaco-editor') | null = null;
33
36
  const isDark = $derived(themeStore.isDark);
34
37
 
35
- // Binary file preview state
36
- let blobUrl = $state<string | null>(null);
37
- let pdfBlobUrl = $state<string | null>(null);
38
- let isBinaryLoading = $state(false);
39
-
40
- // File type detection helpers
41
- function isImageFile(fileName: string): boolean {
42
- const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
43
- return ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp'].includes(ext);
44
- }
45
-
46
- function isSvgFile(fileName: string): boolean {
47
- return fileName.toLowerCase().endsWith('.svg');
48
- }
49
-
50
- function isPdfFile(fileName: string): boolean {
51
- return fileName.toLowerCase().endsWith('.pdf');
52
- }
53
-
54
- function isPreviewableBinary(fileName: string): boolean {
55
- return isImageFile(fileName) || isSvgFile(fileName) || isPdfFile(fileName);
56
- }
57
-
58
- // Load binary content for preview
59
- async function loadBinaryPreview(filePath: string) {
38
+ // Derive absolute path for binary preview
39
+ const binaryPreviewPath = $derived.by(() => {
40
+ if (!activeDiff?.isBinary || activeDiff.status === 'D') return null;
41
+ const filePath = activeDiff.newPath || activeDiff.oldPath;
42
+ if (!filePath) return null;
43
+ const fileName = getFileName(filePath);
44
+ if (!isPreviewableFile(fileName)) return null;
60
45
  const projectPath = projectState.currentProject?.path;
61
- if (!projectPath) return;
62
-
63
- const absolutePath = `${projectPath}/${filePath}`;
64
- isBinaryLoading = true;
65
-
66
- try {
67
- const response = await ws.http('files:read-content', { path: absolutePath });
68
-
69
- if (response.content) {
70
- const binaryString = atob(response.content);
71
- const bytes = new Uint8Array(binaryString.length);
72
- for (let i = 0; i < binaryString.length; i++) {
73
- bytes[i] = binaryString.charCodeAt(i);
74
- }
75
- const blob = new Blob([bytes], { type: response.contentType || 'application/octet-stream' });
76
-
77
- if (isPdfFile(filePath)) {
78
- if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl);
79
- pdfBlobUrl = URL.createObjectURL(blob);
80
- } else {
81
- if (blobUrl) URL.revokeObjectURL(blobUrl);
82
- blobUrl = URL.createObjectURL(blob);
83
- }
84
- }
85
- } catch (err) {
86
- debug.error('git', 'Failed to load binary preview:', err);
87
- } finally {
88
- isBinaryLoading = false;
89
- }
90
- }
91
-
92
- function cleanupBlobUrls() {
93
- if (blobUrl) {
94
- URL.revokeObjectURL(blobUrl);
95
- blobUrl = null;
96
- }
97
- if (pdfBlobUrl) {
98
- URL.revokeObjectURL(pdfBlobUrl);
99
- pdfBlobUrl = null;
100
- }
101
- }
46
+ if (!projectPath) return null;
47
+ return `${projectPath}/${filePath}`;
48
+ });
102
49
 
103
- // Load binary preview when activeDiff changes to a binary file
104
- $effect(() => {
105
- const currentDiff = activeDiff;
106
- if (currentDiff?.isBinary) {
107
- const filePath = currentDiff.newPath || currentDiff.oldPath;
108
- if (filePath && isPreviewableBinary(getFileName(filePath))) {
109
- // Status 'D' means file is deleted, can't preview
110
- if (currentDiff.status !== 'D') {
111
- untrack(() => {
112
- cleanupBlobUrls();
113
- loadBinaryPreview(filePath);
114
- });
115
- }
116
- }
117
- } else {
118
- untrack(() => cleanupBlobUrls());
119
- }
50
+ const binaryFileName = $derived.by(() => {
51
+ if (!activeDiff) return '';
52
+ return getFileName(activeDiff.newPath || activeDiff.oldPath);
120
53
  });
121
54
 
122
55
  // Build full content from hunks
@@ -162,6 +95,19 @@
162
95
  return path.split(/[\\/]/).pop() || path;
163
96
  }
164
97
 
98
+ function openInFilesPanel() {
99
+ if (!activeDiff) return;
100
+ const visiblePanels = getVisiblePanels(workspaceState.layout);
101
+ if (!visiblePanels.includes('files')) return;
102
+ const basePath = projectState.currentProject?.path;
103
+ if (!basePath) return;
104
+ const relativePath = activeDiff.newPath || activeDiff.oldPath;
105
+ const separator = basePath.includes('\\') ? '\\' : '/';
106
+ requestRevealFile(`${basePath}${separator}${relativePath}`);
107
+ }
108
+
109
+ const isFilesPanelVisible = $derived(getVisiblePanels(workspaceState.layout).includes('files'));
110
+
165
111
  async function initDiffEditor() {
166
112
  if (!containerRef || !activeDiff) return;
167
113
 
@@ -273,7 +219,6 @@
273
219
  diffEditorInstance.dispose();
274
220
  diffEditorInstance = null;
275
221
  }
276
- cleanupBlobUrls();
277
222
  });
278
223
  </script>
279
224
 
@@ -288,20 +233,30 @@
288
233
  <span>Select a file to view diff</span>
289
234
  </div>
290
235
  {:else}
291
- <!-- File header (like FileViewer) -->
236
+ <!-- File header -->
292
237
  <div class="flex-shrink-0 flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-700">
293
- <div class="flex items-center gap-2 min-w-0 flex-1">
294
- <Icon name={getFileIcon(getFileName(activeDiff.newPath || activeDiff.oldPath)) as IconName} class="w-5 h-5 shrink-0" />
238
+ <div class="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
239
+ <Icon name={getFileIcon(getFileName(activeDiff.newPath || activeDiff.oldPath)) as IconName} class="w-7 h-7 shrink-0" />
295
240
  <div class="min-w-0 flex-1">
296
- <h3 class="text-xs font-bold text-slate-900 dark:text-slate-100 truncate">
241
+ <h3 class="text-xs sm:text-sm font-bold text-slate-900 dark:text-slate-100 truncate">
297
242
  {getFileName(activeDiff.newPath || activeDiff.oldPath)}
298
243
  </h3>
299
- <p class="text-3xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
244
+ <p class="text-xs text-slate-600 dark:text-slate-400 truncate mt-0.5">
300
245
  {activeDiff.newPath || activeDiff.oldPath}
301
246
  </p>
302
247
  </div>
303
248
  </div>
304
- <div class="flex items-center gap-2 shrink-0">
249
+ <div class="flex items-center gap-1.5 sm:gap-1 flex-shrink-0">
250
+ {#if isFilesPanelVisible && activeDiff.status !== 'D'}
251
+ <button
252
+ type="button"
253
+ class="flex p-2 text-slate-600 dark:text-slate-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/30 rounded-lg transition-all duration-200 cursor-pointer"
254
+ onclick={openInFilesPanel}
255
+ title="Open in Files panel"
256
+ >
257
+ <Icon name="lucide:file-symlink" class="w-4 h-4" />
258
+ </button>
259
+ {/if}
305
260
  <span class="text-3xs font-bold px-1.5 py-0.5 rounded {getGitStatusBadgeColor(activeDiff.status)}">
306
261
  {getGitStatusBadgeLabel(activeDiff.status)}
307
262
  </span>
@@ -309,7 +264,6 @@
309
264
  </div>
310
265
 
311
266
  {#if activeDiff.isBinary}
312
- {@const fileName = getFileName(activeDiff.newPath || activeDiff.oldPath)}
313
267
  {@const isDeleted = activeDiff.status === 'D'}
314
268
  {#if isDeleted}
315
269
  <!-- Deleted binary file -->
@@ -320,43 +274,9 @@
320
274
  This binary file has been deleted and cannot be previewed.
321
275
  </p>
322
276
  </div>
323
- {:else if isImageFile(fileName) || isSvgFile(fileName)}
324
- <!-- Image / SVG preview -->
325
- <div class="flex-1 flex items-center justify-center p-4 overflow-hidden 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')]">
326
- {#if isBinaryLoading}
327
- <div class="w-5 h-5 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
328
- {:else if blobUrl}
329
- <img
330
- src={blobUrl}
331
- alt={fileName}
332
- class="max-w-full max-h-full object-contain"
333
- />
334
- {:else}
335
- <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
336
- <Icon name="lucide:image-off" class="w-8 h-8 opacity-40" />
337
- <span>Failed to load preview</span>
338
- </div>
339
- {/if}
340
- </div>
341
- {:else if isPdfFile(fileName)}
342
- <!-- PDF preview -->
343
- <div class="flex-1 h-full w-full">
344
- {#if isBinaryLoading}
345
- <div class="flex items-center justify-center h-full">
346
- <div class="w-5 h-5 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
347
- </div>
348
- {:else if pdfBlobUrl}
349
- <iframe
350
- src={pdfBlobUrl}
351
- title={fileName}
352
- class="w-full h-full border-0"
353
- ></iframe>
354
- {:else}
355
- <div class="flex flex-col items-center justify-center h-full gap-2 text-slate-500 text-xs">
356
- <Icon name="lucide:file-x" class="w-8 h-8 opacity-40" />
357
- <span>Failed to load PDF preview</span>
358
- </div>
359
- {/if}
277
+ {:else if binaryPreviewPath}
278
+ <div class="flex-1 overflow-hidden">
279
+ <MediaPreview fileName={binaryFileName} filePath={binaryPreviewPath} />
360
280
  </div>
361
281
  {:else}
362
282
  <!-- Generic binary file -->
@@ -4,6 +4,9 @@
4
4
  import { getGitStatusLabel, getGitStatusColor } from '$frontend/utils/git-status';
5
5
  import type { GitFileChange } from '$shared/types/git';
6
6
  import type { IconName } from '$shared/types/ui/icons';
7
+ import { requestRevealFile } from '$frontend/stores/core/files.svelte';
8
+ import { getVisiblePanels, workspaceState } from '$frontend/stores/ui/workspace.svelte';
9
+ import { projectState } from '$frontend/stores/core/projects.svelte';
7
10
 
8
11
  interface Props {
9
12
  file: GitFileChange;
@@ -28,6 +31,15 @@
28
31
  return parts.join('/');
29
32
  });
30
33
  const fileIcon = $derived(getFileIcon(fileName) as IconName);
34
+ const isFilesPanelVisible = $derived(getVisiblePanels(workspaceState.layout).includes('files'));
35
+
36
+ function openInFilesPanel(e: MouseEvent) {
37
+ e.stopPropagation();
38
+ const basePath = projectState.currentProject?.path;
39
+ if (!basePath) return;
40
+ const separator = basePath.includes('\\') ? '\\' : '/';
41
+ requestRevealFile(`${basePath}${separator}${file.path}`);
42
+ }
31
43
  </script>
32
44
 
33
45
  <div
@@ -57,6 +69,16 @@
57
69
 
58
70
  <!-- Actions - always visible -->
59
71
  <div class="flex items-center gap-0.5 shrink-0">
72
+ {#if isFilesPanelVisible && statusCode !== 'D'}
73
+ <button
74
+ type="button"
75
+ class="flex items-center justify-center w-6 h-6 rounded-md text-slate-400 hover:bg-violet-500/10 hover:text-violet-500 transition-colors bg-transparent border-none cursor-pointer"
76
+ onclick={openInFilesPanel}
77
+ title="Open in Files panel"
78
+ >
79
+ <Icon name="lucide:file-symlink" class="w-3.5 h-3.5" />
80
+ </button>
81
+ {/if}
60
82
  {#if section === 'staged'}
61
83
  <button
62
84
  type="button"
@@ -160,6 +160,7 @@
160
160
  if (currentProjectId && currentProjectId !== previousProjectId && previousProjectId !== '') {
161
161
  debug.log('preview', `🔄 Project changed: ${previousProjectId} → ${currentProjectId}`);
162
162
  previousProjectId = currentProjectId;
163
+ sessionsRecovered = false; // Reset for new project (enables empty tab creation if needed)
163
164
  coordinator.switchProject();
164
165
  }
165
166
  });