@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.
- package/backend/chat/stream-manager.ts +136 -10
- package/backend/database/queries/session-queries.ts +9 -0
- package/backend/engine/adapters/claude/error-handler.ts +7 -2
- package/backend/engine/adapters/claude/stream.ts +21 -3
- package/backend/engine/adapters/opencode/message-converter.ts +37 -2
- package/backend/index.ts +25 -3
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/backend/ws/user/crud.ts +6 -3
- package/frontend/components/chat/input/ChatInput.svelte +6 -1
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- package/frontend/components/chat/message/MessageBubble.svelte +22 -1
- package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- package/frontend/components/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +23 -144
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
- package/frontend/components/preview/browser/components/Container.svelte +2 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/WorkspaceLayout.svelte +3 -1
- package/frontend/components/workspace/panels/FilesPanel.svelte +77 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +72 -28
- package/frontend/services/chat/chat.service.ts +6 -1
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
- 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
|
@@ -47,17 +47,24 @@
|
|
|
47
47
|
let isRecovering = $state(false); // Track recovery attempts
|
|
48
48
|
let connectionFailed = $state(false); // Track if connection actually failed (not just slow)
|
|
49
49
|
let hasRequestedScreencastRefresh = false; // Track if we've already requested refresh for this stream
|
|
50
|
+
let screencastRefreshCount = 0; // Track retry count for stuck detection
|
|
50
51
|
let navigationJustCompleted = false; // Track if navigation just completed (for fast refresh)
|
|
51
52
|
|
|
53
|
+
// Canvas snapshot storage for instant tab switching
|
|
54
|
+
// Stores a clone of the canvas per sessionId so switching back shows content immediately
|
|
55
|
+
const canvasSnapshots = new Map<string, HTMLCanvasElement>();
|
|
56
|
+
const MAX_SNAPSHOTS = 10;
|
|
57
|
+
let hasRestoredSnapshot = false; // Prevents canvas clear/reset during streaming start
|
|
58
|
+
|
|
52
59
|
// Recovery is only triggered by ACTUAL failures, not timeouts
|
|
53
60
|
// - ICE connection failed
|
|
54
61
|
// - WebCodecs connection closed unexpectedly
|
|
55
62
|
// - Explicit errors
|
|
56
63
|
const MAX_CONSECUTIVE_FAILURES = 2;
|
|
57
64
|
const HEALTH_CHECK_INTERVAL = 2000; // Check every 2 seconds for connection health
|
|
58
|
-
const FRAME_CHECK_INTERVAL =
|
|
59
|
-
const STUCK_STREAM_TIMEOUT =
|
|
60
|
-
const NAVIGATION_FAST_REFRESH_DELAY =
|
|
65
|
+
const FRAME_CHECK_INTERVAL = 100; // Fallback poll for first frame (primary path is onFirstFrame callback)
|
|
66
|
+
const STUCK_STREAM_TIMEOUT = 3000; // Fallback: Request screencast refresh after 3 seconds of connected but no frame
|
|
67
|
+
const NAVIGATION_FAST_REFRESH_DELAY = 300; // Fast refresh after navigation: 300ms
|
|
61
68
|
|
|
62
69
|
// Sync isStreamReady with hasReceivedFirstFrame for parent component
|
|
63
70
|
$effect(() => {
|
|
@@ -73,6 +80,10 @@
|
|
|
73
80
|
if (lastProjectId && currentProjectId && lastProjectId !== currentProjectId) {
|
|
74
81
|
debug.log('webcodecs', `🔄 Project changed (${lastProjectId} → ${currentProjectId}), destroying old WebCodecs service`);
|
|
75
82
|
|
|
83
|
+
// Clear canvas snapshots - they belong to old project's sessions
|
|
84
|
+
canvasSnapshots.clear();
|
|
85
|
+
hasRestoredSnapshot = false;
|
|
86
|
+
|
|
76
87
|
// Destroy old service
|
|
77
88
|
if (webCodecsService) {
|
|
78
89
|
webCodecsService.destroy();
|
|
@@ -416,7 +427,10 @@
|
|
|
416
427
|
|
|
417
428
|
isStartingStream = true;
|
|
418
429
|
isStreamStarting = true; // Show loading overlay
|
|
419
|
-
|
|
430
|
+
// Don't reset if we restored a snapshot - keep showing it
|
|
431
|
+
if (!hasRestoredSnapshot) {
|
|
432
|
+
hasReceivedFirstFrame = false; // Reset first frame state
|
|
433
|
+
}
|
|
420
434
|
|
|
421
435
|
try {
|
|
422
436
|
// If streaming a different session, stop first
|
|
@@ -480,6 +494,25 @@
|
|
|
480
494
|
onStatsUpdate(stats);
|
|
481
495
|
});
|
|
482
496
|
|
|
497
|
+
// Setup first frame handler - fires immediately when first frame decoded
|
|
498
|
+
// This eliminates the 500ms polling delay for hiding the loading overlay
|
|
499
|
+
webCodecsService.setFirstFrameHandler(() => {
|
|
500
|
+
if (!hasReceivedFirstFrame) {
|
|
501
|
+
debug.log('webcodecs', 'First frame callback - immediately updating UI');
|
|
502
|
+
hasReceivedFirstFrame = true;
|
|
503
|
+
consecutiveFailures = 0;
|
|
504
|
+
connectionFailed = false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Always reset reconnecting state on first real frame
|
|
508
|
+
// (outside !hasReceivedFirstFrame to handle snapshot + reconnect case)
|
|
509
|
+
if (isReconnecting) {
|
|
510
|
+
setTimeout(() => {
|
|
511
|
+
isReconnecting = false;
|
|
512
|
+
}, 300);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
483
516
|
// Setup cursor change handler
|
|
484
517
|
webCodecsService.setOnCursorChange((cursor: string) => {
|
|
485
518
|
updateCanvasCursor(cursor);
|
|
@@ -491,8 +524,8 @@
|
|
|
491
524
|
|
|
492
525
|
let success = false;
|
|
493
526
|
let retries = 0;
|
|
494
|
-
const maxRetries = 5;
|
|
495
|
-
const retryDelay =
|
|
527
|
+
const maxRetries = 5;
|
|
528
|
+
const retryDelay = 300;
|
|
496
529
|
|
|
497
530
|
while (!success && retries < maxRetries) {
|
|
498
531
|
try {
|
|
@@ -502,7 +535,8 @@
|
|
|
502
535
|
isConnected = true;
|
|
503
536
|
activeStreamingSessionId = sessionId;
|
|
504
537
|
consecutiveFailures = 0; // Reset failure counter on success
|
|
505
|
-
startHealthCheck(); //
|
|
538
|
+
startHealthCheck(hasRestoredSnapshot); // Skip first frame reset if snapshot
|
|
539
|
+
hasRestoredSnapshot = false; // Reset after using
|
|
506
540
|
debug.log('webcodecs', 'Streaming started successfully');
|
|
507
541
|
break;
|
|
508
542
|
}
|
|
@@ -533,6 +567,7 @@
|
|
|
533
567
|
} finally {
|
|
534
568
|
isStartingStream = false;
|
|
535
569
|
isStreamStarting = false; // Hide "Launching browser..." (but may still show "Connecting..." until first frame)
|
|
570
|
+
hasRestoredSnapshot = false; // Always reset in finally
|
|
536
571
|
}
|
|
537
572
|
}
|
|
538
573
|
|
|
@@ -561,6 +596,7 @@
|
|
|
561
596
|
}
|
|
562
597
|
connectionFailed = false;
|
|
563
598
|
hasRequestedScreencastRefresh = false; // Reset for new stream
|
|
599
|
+
screencastRefreshCount = 0; // Reset retry counter
|
|
564
600
|
|
|
565
601
|
const startTime = Date.now();
|
|
566
602
|
|
|
@@ -587,6 +623,7 @@
|
|
|
587
623
|
consecutiveFailures = 0;
|
|
588
624
|
connectionFailed = false;
|
|
589
625
|
hasRequestedScreencastRefresh = false; // Reset on success
|
|
626
|
+
screencastRefreshCount = 0; // Reset retry counter on success
|
|
590
627
|
|
|
591
628
|
// Reset reconnecting state after successful frame reception
|
|
592
629
|
// This completes the fast reconnect cycle
|
|
@@ -621,10 +658,21 @@
|
|
|
621
658
|
// STUCK STREAM DETECTION (FALLBACK): If connected but no first frame for too long,
|
|
622
659
|
// request screencast refresh (hot-swap) to restart CDP screencast.
|
|
623
660
|
// This handles cases where WebRTC is connected but CDP frames aren't flowing.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
661
|
+
// Retries: 1st at 3s (screencast refresh), 2nd at 6s (another refresh), 3rd at 10s (full recovery)
|
|
662
|
+
if (stats?.isConnected && !stats?.firstFrameRendered && !hasRequestedScreencastRefresh) {
|
|
663
|
+
const MAX_SCREENCAST_RETRIES = 2;
|
|
664
|
+
const retryThreshold = STUCK_STREAM_TIMEOUT + (screencastRefreshCount * 3000); // 3s, 6s
|
|
665
|
+
|
|
666
|
+
if (elapsed >= retryThreshold && screencastRefreshCount < MAX_SCREENCAST_RETRIES) {
|
|
667
|
+
screencastRefreshCount++;
|
|
668
|
+
debug.warn('webcodecs', `Stream stuck (connected, no frame for ${elapsed}ms), screencast refresh attempt ${screencastRefreshCount}/${MAX_SCREENCAST_RETRIES}`);
|
|
669
|
+
onRequestScreencastRefresh();
|
|
670
|
+
} else if (elapsed >= 10000 && screencastRefreshCount >= MAX_SCREENCAST_RETRIES) {
|
|
671
|
+
// Screencast refreshes didn't help - attempt full recovery
|
|
672
|
+
debug.warn('webcodecs', `Stream still stuck after ${screencastRefreshCount} screencast refreshes (${elapsed}ms), attempting full recovery`);
|
|
673
|
+
hasRequestedScreencastRefresh = true; // Prevent further retries
|
|
674
|
+
attemptRecovery();
|
|
675
|
+
}
|
|
628
676
|
}
|
|
629
677
|
|
|
630
678
|
}, FRAME_CHECK_INTERVAL);
|
|
@@ -770,11 +818,11 @@
|
|
|
770
818
|
lastStartRequestId = null; // Clear to allow new requests
|
|
771
819
|
// Note: Don't reset hasReceivedFirstFrame here - let startStreaming do it
|
|
772
820
|
// This prevents flashing when switching tabs
|
|
773
|
-
// Clear canvas to prevent stale frames, BUT keep last frame during navigation
|
|
774
|
-
if (!isNavigating) {
|
|
821
|
+
// Clear canvas to prevent stale frames, BUT keep last frame during navigation or snapshot restore
|
|
822
|
+
if (!isNavigating && !hasRestoredSnapshot) {
|
|
775
823
|
clearCanvas();
|
|
776
824
|
} else {
|
|
777
|
-
debug.log('webcodecs',
|
|
825
|
+
debug.log('webcodecs', `Skipping canvas clear - navigation: ${isNavigating}, snapshot: ${hasRestoredSnapshot}`);
|
|
778
826
|
}
|
|
779
827
|
}
|
|
780
828
|
}
|
|
@@ -813,10 +861,52 @@
|
|
|
813
861
|
const needsStreaming = !isWebCodecsActive || activeStreamingSessionId !== sessionId;
|
|
814
862
|
|
|
815
863
|
if (needsStreaming) {
|
|
816
|
-
// Clear canvas immediately when session changes to prevent stale frames
|
|
817
864
|
if (activeStreamingSessionId !== sessionId) {
|
|
818
|
-
|
|
819
|
-
|
|
865
|
+
// SNAPSHOT: Save current canvas before switching to new session
|
|
866
|
+
if (activeStreamingSessionId && hasReceivedFirstFrame && canvasElement.width > 0) {
|
|
867
|
+
try {
|
|
868
|
+
const clone = document.createElement('canvas');
|
|
869
|
+
clone.width = canvasElement.width;
|
|
870
|
+
clone.height = canvasElement.height;
|
|
871
|
+
const cloneCtx = clone.getContext('2d');
|
|
872
|
+
if (cloneCtx) {
|
|
873
|
+
cloneCtx.drawImage(canvasElement, 0, 0);
|
|
874
|
+
// Limit snapshot count
|
|
875
|
+
if (canvasSnapshots.size >= MAX_SNAPSHOTS) {
|
|
876
|
+
const firstKey = canvasSnapshots.keys().next().value;
|
|
877
|
+
if (firstKey) canvasSnapshots.delete(firstKey);
|
|
878
|
+
}
|
|
879
|
+
canvasSnapshots.set(activeStreamingSessionId, clone);
|
|
880
|
+
debug.log('webcodecs', `📸 Saved canvas snapshot for session ${activeStreamingSessionId}`);
|
|
881
|
+
}
|
|
882
|
+
} catch (e) {
|
|
883
|
+
debug.warn('webcodecs', 'Failed to capture canvas snapshot:', e);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// SNAPSHOT: Restore for new session if available
|
|
888
|
+
const existingSnapshot = canvasSnapshots.get(sessionId);
|
|
889
|
+
if (existingSnapshot) {
|
|
890
|
+
setupCanvasInternal(); // Ensure canvas dimensions are correct
|
|
891
|
+
try {
|
|
892
|
+
const ctx = canvasElement.getContext('2d');
|
|
893
|
+
if (ctx) {
|
|
894
|
+
ctx.drawImage(existingSnapshot, 0, 0, canvasElement.width, canvasElement.height);
|
|
895
|
+
hasRestoredSnapshot = true;
|
|
896
|
+
// Don't reset hasReceivedFirstFrame - snapshot is visible
|
|
897
|
+
debug.log('webcodecs', `📸 Restored canvas snapshot for session ${sessionId}`);
|
|
898
|
+
}
|
|
899
|
+
} catch (e) {
|
|
900
|
+
debug.warn('webcodecs', 'Failed to restore canvas snapshot:', e);
|
|
901
|
+
hasRestoredSnapshot = false;
|
|
902
|
+
clearCanvas();
|
|
903
|
+
hasReceivedFirstFrame = false;
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
hasRestoredSnapshot = false;
|
|
907
|
+
clearCanvas();
|
|
908
|
+
hasReceivedFirstFrame = false; // Reset to show loading overlay
|
|
909
|
+
}
|
|
820
910
|
}
|
|
821
911
|
|
|
822
912
|
// Stop existing streaming first if session changed
|
|
@@ -831,12 +921,10 @@
|
|
|
831
921
|
await startStreaming();
|
|
832
922
|
};
|
|
833
923
|
|
|
834
|
-
//
|
|
835
|
-
// This is especially important during viewport/device change
|
|
836
|
-
// when session is being recreated
|
|
924
|
+
// Small delay to ensure backend session is ready
|
|
837
925
|
const timeout = setTimeout(() => {
|
|
838
926
|
doStartStreaming();
|
|
839
|
-
},
|
|
927
|
+
}, 50);
|
|
840
928
|
|
|
841
929
|
return () => clearTimeout(timeout);
|
|
842
930
|
}
|
|
@@ -1039,6 +1127,7 @@
|
|
|
1039
1127
|
|
|
1040
1128
|
onDestroy(() => {
|
|
1041
1129
|
stopHealthCheck(); // Stop health monitoring
|
|
1130
|
+
canvasSnapshots.clear(); // Free snapshot memory
|
|
1042
1131
|
if (webCodecsService) {
|
|
1043
1132
|
webCodecsService.destroy();
|
|
1044
1133
|
webCodecsService = null;
|
|
@@ -52,8 +52,9 @@
|
|
|
52
52
|
let previewContainer = $state<HTMLDivElement | undefined>();
|
|
53
53
|
|
|
54
54
|
// Solid loading overlay: shown during initial load states
|
|
55
|
+
// Skip when lastFrameData exists (tab was previously loaded - snapshot handles display)
|
|
55
56
|
const showSolidOverlay = $derived(
|
|
56
|
-
isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting)
|
|
57
|
+
isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting && !lastFrameData)
|
|
57
58
|
);
|
|
58
59
|
|
|
59
60
|
// Navigation overlay state with debounce to prevent flickering during state transitions
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
{@const isActive = tab.id === activeTabId}
|
|
223
223
|
<button
|
|
224
224
|
type="button"
|
|
225
|
-
class="group relative flex items-center justify-center gap-1
|
|
225
|
+
class="group relative flex items-center justify-center gap-1 pr-2 pl-3 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
|
|
226
226
|
{isActive
|
|
227
227
|
? 'text-violet-600 dark:text-violet-400'
|
|
228
228
|
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
|
|
@@ -230,13 +230,6 @@
|
|
|
230
230
|
role="tab"
|
|
231
231
|
tabindex="0"
|
|
232
232
|
>
|
|
233
|
-
{#if tab.id === mcpControlledTabId}
|
|
234
|
-
<Icon name="lucide:bot" class="w-3 h-3 flex-shrink-0 text-amber-500" />
|
|
235
|
-
{:else if tab.isLoading}
|
|
236
|
-
<Icon name="lucide:loader-circle" class="w-3 h-3 animate-spin flex-shrink-0" />
|
|
237
|
-
{:else}
|
|
238
|
-
<Icon name="lucide:globe" class="w-3 h-3 flex-shrink-0" />
|
|
239
|
-
{/if}
|
|
240
233
|
<span class="truncate max-w-28" title={tab.url}>
|
|
241
234
|
{tab.title || 'New Tab'}
|
|
242
235
|
</span>
|
|
@@ -486,7 +486,10 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
486
486
|
|
|
487
487
|
if (!existingTabs || existingTabs.count === 0) {
|
|
488
488
|
debug.log('preview', '📭 No existing sessions to recover');
|
|
489
|
-
|
|
489
|
+
// Notify parent about 0-tab recovery (enables empty tab creation)
|
|
490
|
+
if (onSessionsRecovered) {
|
|
491
|
+
onSessionsRecovered(0);
|
|
492
|
+
}
|
|
490
493
|
return;
|
|
491
494
|
}
|
|
492
495
|
|
|
@@ -781,7 +784,7 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
781
784
|
const newProjectId = getProjectId();
|
|
782
785
|
debug.log('preview', `🔄 Switching to project: ${newProjectId}`);
|
|
783
786
|
|
|
784
|
-
// Set restore lock during project switch
|
|
787
|
+
// Set restore lock during entire project switch (prevents race conditions)
|
|
785
788
|
isRestoring = true;
|
|
786
789
|
|
|
787
790
|
try {
|
|
@@ -795,13 +798,18 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
|
|
|
795
798
|
browserCleanup.clearAll();
|
|
796
799
|
|
|
797
800
|
// Recover sessions from new project
|
|
798
|
-
// Note: recoverExistingSessions will handle its own lock
|
|
799
|
-
isRestoring = false;
|
|
800
801
|
await recoverExistingSessions();
|
|
801
802
|
} catch (error) {
|
|
802
803
|
debug.error('preview', '❌ Error switching project:', error);
|
|
804
|
+
} finally {
|
|
803
805
|
isRestoring = false;
|
|
804
806
|
}
|
|
807
|
+
|
|
808
|
+
// After recovery and lock release, create empty tab if no tabs exist
|
|
809
|
+
if (tabManager.getAllTabs().length === 0) {
|
|
810
|
+
debug.log('preview', '📭 No tabs after project switch, creating empty tab');
|
|
811
|
+
createNewTab('');
|
|
812
|
+
}
|
|
805
813
|
}
|
|
806
814
|
|
|
807
815
|
return {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
{@const isActive = session.isActive}
|
|
38
38
|
<button
|
|
39
39
|
type="button"
|
|
40
|
-
class="group relative flex items-center justify-center gap-1
|
|
40
|
+
class="group relative flex items-center justify-center gap-1 pr-2 pl-3 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
|
|
41
41
|
{isActive
|
|
42
42
|
? 'text-violet-600 dark:text-violet-400'
|
|
43
43
|
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
role="tab"
|
|
46
46
|
tabindex="0"
|
|
47
47
|
>
|
|
48
|
-
<Icon name="lucide:terminal" class="w-3 h-3 flex-shrink-0" />
|
|
49
48
|
<span class="truncate max-w-28">{session.name}</span>
|
|
50
49
|
<!-- Close button -->
|
|
51
50
|
<span
|
|
@@ -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();
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { showConfirm } from '$frontend/stores/ui/dialog.svelte';
|
|
19
19
|
import { getFileIcon } from '$frontend/utils/file-icon-mappings';
|
|
20
20
|
import type { IconName } from '$shared/types/ui/icons';
|
|
21
|
+
import { fileState, clearRevealRequest } from '$frontend/stores/core/files.svelte';
|
|
21
22
|
|
|
22
23
|
// Props
|
|
23
24
|
interface Props {
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
savedContent: string;
|
|
52
53
|
isLoading: boolean;
|
|
53
54
|
externallyChanged?: boolean;
|
|
55
|
+
isBinary?: boolean;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
let openTabs = $state<EditorTab[]>([]);
|
|
@@ -68,6 +70,7 @@
|
|
|
68
70
|
let displayFile = $state<FileNode | null>(null);
|
|
69
71
|
let displayLoading = $state(false);
|
|
70
72
|
let displayTargetLine = $state<number | undefined>(undefined);
|
|
73
|
+
let displayIsBinary = $state(false);
|
|
71
74
|
let displayExternallyChanged = $state(false);
|
|
72
75
|
|
|
73
76
|
// Sync display state when active tab changes
|
|
@@ -78,12 +81,14 @@
|
|
|
78
81
|
displaySavedContent = activeTab.savedContent;
|
|
79
82
|
displayLoading = activeTab.isLoading;
|
|
80
83
|
displayExternallyChanged = activeTab.externallyChanged || false;
|
|
84
|
+
displayIsBinary = activeTab.isBinary || false;
|
|
81
85
|
} else {
|
|
82
86
|
displayFile = null;
|
|
83
87
|
displayContent = '';
|
|
84
88
|
displaySavedContent = '';
|
|
85
89
|
displayLoading = false;
|
|
86
90
|
displayExternallyChanged = false;
|
|
91
|
+
displayIsBinary = false;
|
|
87
92
|
}
|
|
88
93
|
});
|
|
89
94
|
|
|
@@ -341,9 +346,10 @@
|
|
|
341
346
|
try {
|
|
342
347
|
const data = await ws.http('files:read-file', { file_path: filePath });
|
|
343
348
|
const content = data.content || '';
|
|
349
|
+
const isBinary = data.isBinary || false;
|
|
344
350
|
openTabs = openTabs.map(t =>
|
|
345
351
|
t.file.path === filePath
|
|
346
|
-
? { ...t, currentContent: content, savedContent: content, isLoading: false }
|
|
352
|
+
? { ...t, currentContent: content, savedContent: content, isLoading: false, isBinary }
|
|
347
353
|
: t
|
|
348
354
|
);
|
|
349
355
|
// Update display if this is the active tab
|
|
@@ -351,6 +357,7 @@
|
|
|
351
357
|
displayContent = content;
|
|
352
358
|
displaySavedContent = content;
|
|
353
359
|
displayLoading = false;
|
|
360
|
+
displayIsBinary = isBinary;
|
|
354
361
|
}
|
|
355
362
|
return true;
|
|
356
363
|
} catch (err) {
|
|
@@ -377,6 +384,7 @@
|
|
|
377
384
|
displayContent = tab.currentContent;
|
|
378
385
|
displaySavedContent = tab.savedContent;
|
|
379
386
|
displayLoading = tab.isLoading;
|
|
387
|
+
displayIsBinary = tab.isBinary || false;
|
|
380
388
|
}
|
|
381
389
|
}
|
|
382
390
|
|
|
@@ -1086,6 +1094,72 @@
|
|
|
1086
1094
|
prevTwoColumnMode = isTwoColumnMode;
|
|
1087
1095
|
});
|
|
1088
1096
|
|
|
1097
|
+
// Reveal and open file in editor when requested from external components (e.g. chat tools)
|
|
1098
|
+
$effect(() => {
|
|
1099
|
+
const revealPath = fileState.revealRequest;
|
|
1100
|
+
if (!revealPath || !projectPath) return;
|
|
1101
|
+
|
|
1102
|
+
clearRevealRequest();
|
|
1103
|
+
|
|
1104
|
+
// Expand all parent directories in the tree
|
|
1105
|
+
const relativePath = revealPath.startsWith(projectPath)
|
|
1106
|
+
? revealPath.slice(projectPath.length).replace(/^[/\\]/, '')
|
|
1107
|
+
: '';
|
|
1108
|
+
if (relativePath) {
|
|
1109
|
+
const parts = relativePath.split(/[/\\]/);
|
|
1110
|
+
let currentPath = projectPath;
|
|
1111
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
1112
|
+
currentPath += '/' + parts[i];
|
|
1113
|
+
expandedFolders.add(currentPath);
|
|
1114
|
+
}
|
|
1115
|
+
expandedFolders = new Set(expandedFolders);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Open file in editor tab (handleFileOpen also handles missing tree nodes)
|
|
1119
|
+
revealAndOpenFile(revealPath);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
async function revealAndOpenFile(filePath: string) {
|
|
1123
|
+
const existingTab = openTabs.find(t => t.file.path === filePath);
|
|
1124
|
+
if (existingTab) {
|
|
1125
|
+
// Tab already open — just activate it
|
|
1126
|
+
activeTabPath = filePath;
|
|
1127
|
+
if (!isTwoColumnMode) viewMode = 'viewer';
|
|
1128
|
+
scrollToActiveFile(filePath);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Create new tab
|
|
1133
|
+
let file = findFileInTree(projectFiles, filePath);
|
|
1134
|
+
if (!file) {
|
|
1135
|
+
const fileName = filePath.split(/[/\\]/).pop() || 'Untitled';
|
|
1136
|
+
file = { name: fileName, path: filePath, type: 'file', size: 0, modified: new Date() };
|
|
1137
|
+
}
|
|
1138
|
+
const newTab: EditorTab = {
|
|
1139
|
+
file,
|
|
1140
|
+
currentContent: '',
|
|
1141
|
+
savedContent: '',
|
|
1142
|
+
isLoading: true
|
|
1143
|
+
};
|
|
1144
|
+
openTabs = [...openTabs, newTab];
|
|
1145
|
+
activeTabPath = filePath;
|
|
1146
|
+
if (!isTwoColumnMode) viewMode = 'viewer';
|
|
1147
|
+
|
|
1148
|
+
// Load content and verify file exists on disk
|
|
1149
|
+
const success = await loadTabContent(filePath);
|
|
1150
|
+
if (!success) {
|
|
1151
|
+
openTabs = openTabs.filter(t => t.file.path !== filePath);
|
|
1152
|
+
if (activeTabPath === filePath) {
|
|
1153
|
+
activeTabPath = openTabs.length > 0 ? openTabs[openTabs.length - 1].file.path : null;
|
|
1154
|
+
if (!activeTabPath && !isTwoColumnMode) viewMode = 'tree';
|
|
1155
|
+
}
|
|
1156
|
+
showErrorAlert('File no longer exists on disk.', 'File Not Found');
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
scrollToActiveFile(filePath);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1089
1163
|
// Save state to persistent storage on component destruction (mobile/desktop switch)
|
|
1090
1164
|
onDestroy(() => {
|
|
1091
1165
|
if (projectPath) {
|
|
@@ -1269,6 +1343,8 @@
|
|
|
1269
1343
|
onToggleWordWrap={() => { wordWrapEnabled = !wordWrapEnabled; }}
|
|
1270
1344
|
externallyChanged={displayExternallyChanged}
|
|
1271
1345
|
onForceReload={forceReloadTab}
|
|
1346
|
+
isBinary={displayIsBinary}
|
|
1347
|
+
projectPath={projectPath}
|
|
1272
1348
|
/>
|
|
1273
1349
|
</div>
|
|
1274
1350
|
</div>
|