@myrialabs/clopen 0.2.4 → 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/opencode/message-converter.ts +37 -2
- package/backend/ws/user/crud.ts +6 -3
- package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- package/frontend/components/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +11 -143
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/WorkspaceLayout.svelte +3 -1
- package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +72 -28
- package/frontend/stores/core/files.svelte.ts +15 -1
- package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
- 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/messaging/tool.ts +1 -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">
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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"
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
let projectToDelete = $state<Project | null>(null);
|
|
27
27
|
let searchQuery = $state('');
|
|
28
28
|
let showTunnelModal = $state(false);
|
|
29
|
+
let hoveredProject = $state<Project | null>(null);
|
|
30
|
+
let tooltipY = $state(0);
|
|
31
|
+
let tooltipX = $state(0);
|
|
29
32
|
|
|
30
33
|
// Derived
|
|
31
34
|
const isCollapsed = $derived(workspaceState.navigatorCollapsed);
|
|
@@ -145,6 +148,17 @@
|
|
|
145
148
|
// Single word: take first 2 letters
|
|
146
149
|
return name.substring(0, 2).toUpperCase();
|
|
147
150
|
}
|
|
151
|
+
|
|
152
|
+
function showProjectTooltip(project: Project, event: MouseEvent) {
|
|
153
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
154
|
+
tooltipX = rect.right + 8;
|
|
155
|
+
tooltipY = rect.top + rect.height / 2;
|
|
156
|
+
hoveredProject = project;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hideProjectTooltip() {
|
|
160
|
+
hoveredProject = null;
|
|
161
|
+
}
|
|
148
162
|
</script>
|
|
149
163
|
|
|
150
164
|
<!-- Project Navigator Sidebar -->
|
|
@@ -315,7 +329,8 @@
|
|
|
315
329
|
? 'bg-violet-500/10 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300'
|
|
316
330
|
: 'bg-slate-200/50 dark:bg-slate-800/50 text-slate-600 dark:text-slate-400 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100'}"
|
|
317
331
|
onclick={() => selectProject(project)}
|
|
318
|
-
|
|
332
|
+
onmouseenter={(e) => showProjectTooltip(project, e)}
|
|
333
|
+
onmouseleave={hideProjectTooltip}
|
|
319
334
|
>
|
|
320
335
|
<span>{getProjectInitials(project.name)}</span>
|
|
321
336
|
<span
|
|
@@ -349,6 +364,17 @@
|
|
|
349
364
|
</nav>
|
|
350
365
|
</aside>
|
|
351
366
|
|
|
367
|
+
<!-- Collapsed project tooltip (fixed position to avoid overflow clipping) -->
|
|
368
|
+
{#if hoveredProject}
|
|
369
|
+
<div
|
|
370
|
+
class="fixed z-50 pointer-events-none flex flex-col py-1.5 px-2.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 shadow-lg whitespace-nowrap"
|
|
371
|
+
style="left: {tooltipX}px; top: {tooltipY}px; transform: translateY(-50%);"
|
|
372
|
+
>
|
|
373
|
+
<span class="text-xs font-semibold text-slate-900 dark:text-slate-100">{hoveredProject.name}</span>
|
|
374
|
+
<span class="text-3xs font-mono text-slate-500 dark:text-slate-400">{hoveredProject.path}</span>
|
|
375
|
+
</div>
|
|
376
|
+
{/if}
|
|
377
|
+
|
|
352
378
|
<!-- Folder Browser (includes its own Modal) -->
|
|
353
379
|
<FolderBrowser
|
|
354
380
|
bind:isOpen={showFolderBrowser}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { initializeSessions } from '$frontend/stores/core/sessions.svelte';
|
|
29
29
|
import { initializeNotifications, notificationStore } from '$frontend/stores/ui/notification.svelte';
|
|
30
30
|
import { applyServerSettings, loadSystemSettings } from '$frontend/stores/features/settings.svelte';
|
|
31
|
+
import { applyTodoPanelState } from '$frontend/stores/ui/todo-panel.svelte';
|
|
31
32
|
import { initPresence } from '$frontend/stores/core/presence.svelte';
|
|
32
33
|
import ws from '$frontend/utils/ws';
|
|
33
34
|
import { debug } from '$shared/utils/logger';
|
|
@@ -84,7 +85,7 @@
|
|
|
84
85
|
|
|
85
86
|
// Step 3: Restore user state from server
|
|
86
87
|
setProgress(30, 'Restoring state...');
|
|
87
|
-
let serverState: { currentProjectId: string | null; lastView: string | null; settings: any; unreadSessions: any } | null = null;
|
|
88
|
+
let serverState: { currentProjectId: string | null; lastView: string | null; settings: any; unreadSessions: any; todoPanelState: any } | null = null;
|
|
88
89
|
try {
|
|
89
90
|
serverState = await ws.http('user:restore-state', {});
|
|
90
91
|
debug.log('workspace', 'Server state restored:', serverState);
|
|
@@ -97,6 +98,7 @@
|
|
|
97
98
|
if (serverState?.settings) {
|
|
98
99
|
applyServerSettings(serverState.settings);
|
|
99
100
|
}
|
|
101
|
+
applyTodoPanelState(serverState?.todoPanelState);
|
|
100
102
|
restoreLastView(serverState?.lastView);
|
|
101
103
|
restoreUnreadSessions(serverState?.unreadSessions);
|
|
102
104
|
await loadSystemSettings();
|