diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 9cfb1e3fa3c..e5e9c480c8c 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; -import { app, Notification, nativeTheme } from "electron"; +import { app, Notification, nativeTheme, webContents } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { localDb } from "main/lib/local-db"; @@ -282,6 +282,22 @@ export async function MainWindow() { console.error(` Error:`, error); }); + // Handle mouse back/forward buttons for webview panes (Windows/Linux). + // `app-command` is not supported on macOS; macOS mouse buttons are handled + // via executeJavaScript injection in usePersistentWebview's dom-ready handler. + window.on("app-command", (_event, command) => { + const focusedGuest = webContents + .getAllWebContents() + .find((wc) => wc.getType() === "webview" && wc.isFocused()); + if (!focusedGuest) return; + + if (command === "browser-backward") { + focusedGuest.navigationHistory.goBack(); + } else if (command === "browser-forward") { + focusedGuest.navigationHistory.goForward(); + } + }); + window.on("close", () => { // Save window state first, before any cleanup const isMaximized = window.isMaximized(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx new file mode 100644 index 00000000000..5877b6f4a97 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/KeepAliveWorkspaces.tsx @@ -0,0 +1,98 @@ +import { Outlet, useMatchRoute } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { WorkspacePage } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page"; + +/** + * Replaces a plain for workspace routes, keeping previously visited + * workspace pages mounted (but hidden) so that Electron elements + * inside BrowserPanes are never removed from the DOM. + * + * For non-workspace routes (settings, welcome, etc.) it renders the normal + * . + * + * Automatically evicts deleted workspaces from the keep-alive list by comparing + * visited IDs against the current workspace list from the database. + */ +export function KeepAliveWorkspaces() { + const matchRoute = useMatchRoute(); + const workspaceMatch = matchRoute({ + to: "/workspace/$workspaceId", + fuzzy: true, + }); + const activeWorkspaceId = + workspaceMatch !== false ? workspaceMatch.workspaceId : null; + + // Track every workspace that has been visited so we can keep them alive. + const [visitedIds, setVisitedIds] = useState([]); + const visitedSetRef = useRef(new Set()); + + useEffect(() => { + if (activeWorkspaceId && !visitedSetRef.current.has(activeWorkspaceId)) { + visitedSetRef.current.add(activeWorkspaceId); + setVisitedIds(Array.from(visitedSetRef.current)); + } + }, [activeWorkspaceId]); + + // Evict deleted workspaces: compare visited IDs against the live list. + const { data: workspaceGroups } = + electronTrpc.workspaces.getAllGrouped.useQuery(); + + const existingWorkspaceIds = useMemo(() => { + if (!workspaceGroups) return null; + const ids = new Set(); + for (const group of workspaceGroups) { + for (const ws of group.workspaces) { + ids.add(ws.id); + } + } + return ids; + }, [workspaceGroups]); + + useEffect(() => { + if (!existingWorkspaceIds) return; + let changed = false; + for (const id of visitedSetRef.current) { + if (!existingWorkspaceIds.has(id)) { + visitedSetRef.current.delete(id); + changed = true; + } + } + if (changed) { + setVisitedIds(Array.from(visitedSetRef.current)); + } + }, [existingWorkspaceIds]); + + // Non-workspace route — fall through to the normal Outlet. + if (!activeWorkspaceId) { + return ; + } + + return ( + <> + {visitedIds.map((id) => { + const isActive = id === activeWorkspaceId; + return ( +
+ +
+ ); + })} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 4be47ca9183..fe00ceed508 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -1,7 +1,6 @@ import { FEATURE_FLAGS } from "@superset/shared/constants"; import { createFileRoute, - Outlet, useMatchRoute, useNavigate, } from "@tanstack/react-router"; @@ -18,6 +17,7 @@ import { MAX_WORKSPACE_SIDEBAR_WIDTH, useWorkspaceSidebarStore, } from "renderer/stores/workspace-sidebar-state"; +import { KeepAliveWorkspaces } from "./components/KeepAliveWorkspaces"; import { TopBar } from "./components/TopBar"; export const Route = createFileRoute("/_authenticated/_dashboard")({ @@ -123,7 +123,7 @@ function DashboardLayout() { )}
- +
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts index 6232b8a8a74..b3689fb1b55 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts @@ -15,32 +15,33 @@ export const PRESET_HOTKEY_IDS: HotkeyId[] = [ export function usePresetHotkeys( openTabWithPreset: (presetIndex: number) => void, + options?: { enabled?: boolean }, ) { - useAppHotkey(PRESET_HOTKEY_IDS[0], () => openTabWithPreset(0), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[0], () => openTabWithPreset(0), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[1], () => openTabWithPreset(1), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[1], () => openTabWithPreset(1), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[2], () => openTabWithPreset(2), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[2], () => openTabWithPreset(2), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[3], () => openTabWithPreset(3), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[3], () => openTabWithPreset(3), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[4], () => openTabWithPreset(4), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[4], () => openTabWithPreset(4), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[5], () => openTabWithPreset(5), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[5], () => openTabWithPreset(5), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[6], () => openTabWithPreset(6), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[6], () => openTabWithPreset(6), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[7], () => openTabWithPreset(7), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[7], () => openTabWithPreset(7), options, [ openTabWithPreset, ]); - useAppHotkey(PRESET_HOTKEY_IDS[8], () => openTabWithPreset(8), undefined, [ + useAppHotkey(PRESET_HOTKEY_IDS[8], () => openTabWithPreset(8), options, [ openTabWithPreset, ]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index ed7656ab114..8fcba2fb776 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -1,5 +1,11 @@ import type { ExternalApp } from "@superset/local-db"; -import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; +import { + createFileRoute, + notFound, + useNavigate, + useParams, + useSearch, +} from "@tanstack/react-router"; import { useCallback, useEffect, useMemo } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { useFileOpenMode } from "renderer/hooks/useFileOpenMode"; @@ -24,6 +30,7 @@ import { UnsavedChangesDialog } from "renderer/screens/main/components/Workspace import { useWorkspaceFileEventBridge } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceFileEvents"; import { useWorkspaceRenameReconciliation } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceRenameReconciliation"; import { WorkspaceInitializingView } from "renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView"; +import { WorkspaceIdProvider } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { WorkspaceLayout } from "renderer/screens/main/components/WorkspaceView/WorkspaceLayout"; import { useCreateOrOpenPR, usePRStatus } from "renderer/screens/main/hooks"; import { @@ -86,8 +93,14 @@ export const Route = createFileRoute( }, }); -function WorkspacePage() { - const { workspaceId } = Route.useParams(); +export function WorkspacePage({ + workspaceIdOverride, + isActive = true, +}: { workspaceIdOverride?: string; isActive?: boolean } = {}) { + const routeParams = useParams({ strict: false }) as { + workspaceId?: string; + }; + const workspaceId = workspaceIdOverride ?? routeParams.workspaceId ?? ""; const { data: workspace } = electronTrpc.workspaces.get.useQuery({ id: workspaceId, }); @@ -102,8 +115,9 @@ function WorkspacePage() { enabled: Boolean(workspace?.worktreePath), }); const navigate = useNavigate(); - const routeNavigate = Route.useNavigate(); - const { tabId: searchTabId, paneId: searchPaneId } = Route.useSearch(); + const searchParams = useSearch({ strict: false }) as Partial; + const searchTabId = searchParams?.tabId; + const searchPaneId = searchParams?.paneId; // Keep the file open mode cache warm for addFileViewerPane useFileOpenMode(); @@ -124,8 +138,13 @@ function WorkspacePage() { state.setFocusedPane(searchTabId, searchPaneId); } - routeNavigate({ search: {}, replace: true }); - }, [searchTabId, searchPaneId, workspaceId, routeNavigate]); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + search: {}, + replace: true, + }); + }, [searchTabId, searchPaneId, workspaceId, navigate]); // Check if workspace is initializing or failed const isInitializing = useIsWorkspaceInitializing(workspaceId); @@ -213,11 +232,12 @@ function WorkspacePage() { [presets, workspaceId, addTab, openPreset], ); - useAppHotkey("NEW_GROUP", () => addTab(workspaceId), undefined, [ + const hotkeyOptions = { enabled: isActive }; + useAppHotkey("NEW_GROUP", () => addTab(workspaceId), hotkeyOptions, [ workspaceId, addTab, ]); - useAppHotkey("NEW_CHAT", () => addChatTab(workspaceId), undefined, [ + useAppHotkey("NEW_CHAT", () => addChatTab(workspaceId), hotkeyOptions, [ workspaceId, addChatTab, ]); @@ -228,16 +248,16 @@ function WorkspacePage() { addChatTab(workspaceId); } }, - undefined, + hotkeyOptions, [workspaceId, reopenClosedTab, addChatTab], ); - useAppHotkey("NEW_BROWSER", () => addBrowserTab(workspaceId), undefined, [ + useAppHotkey("NEW_BROWSER", () => addBrowserTab(workspaceId), hotkeyOptions, [ workspaceId, addBrowserTab, ]); - usePresetHotkeys(openTabWithPreset); + usePresetHotkeys(openTabWithPreset, hotkeyOptions); - useAppHotkey("RUN_WORKSPACE_COMMAND", () => toggleWorkspaceRun(), undefined, [ + useAppHotkey("RUN_WORKSPACE_COMMAND", () => toggleWorkspaceRun(), hotkeyOptions, [ toggleWorkspaceRun, ]); @@ -248,7 +268,7 @@ function WorkspacePage() { requestPaneClose(focusedPaneId); } }, - undefined, + hotkeyOptions, [focusedPaneId], ); useAppHotkey( @@ -258,7 +278,7 @@ function WorkspacePage() { requestTabClose(activeTabId); } }, - undefined, + hotkeyOptions, [activeTabId], ); @@ -270,7 +290,7 @@ function WorkspacePage() { const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; setActiveTab(workspaceId, tabs[prevIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -283,7 +303,7 @@ function WorkspacePage() { index >= tabs.length - 1 || index === -1 ? 0 : index + 1; setActiveTab(workspaceId, tabs[nextIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -295,7 +315,7 @@ function WorkspacePage() { const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; setActiveTab(workspaceId, tabs[prevIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -308,7 +328,7 @@ function WorkspacePage() { index >= tabs.length - 1 || index === -1 ? 0 : index + 1; setActiveTab(workspaceId, tabs[nextIndex].id); }, - undefined, + hotkeyOptions, [workspaceId, activeTabId, tabs, setActiveTab], ); @@ -322,15 +342,15 @@ function WorkspacePage() { [tabs, workspaceId, setActiveTab], ); - useAppHotkey("JUMP_TO_TAB_1", () => switchToTab(0), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_2", () => switchToTab(1), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_3", () => switchToTab(2), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_4", () => switchToTab(3), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_5", () => switchToTab(4), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_6", () => switchToTab(5), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_7", () => switchToTab(6), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_8", () => switchToTab(7), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_9", () => switchToTab(8), undefined, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_1", () => switchToTab(0), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_2", () => switchToTab(1), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_3", () => switchToTab(2), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_4", () => switchToTab(3), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_5", () => switchToTab(4), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_6", () => switchToTab(5), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_7", () => switchToTab(6), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_8", () => switchToTab(7), hotkeyOptions, [switchToTab]); + useAppHotkey("JUMP_TO_TAB_9", () => switchToTab(8), hotkeyOptions, [switchToTab]); useAppHotkey( "PREV_PANE", @@ -341,7 +361,7 @@ function WorkspacePage() { setFocusedPane(activeTabId, prevPaneId); } }, - undefined, + hotkeyOptions, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], ); @@ -354,7 +374,7 @@ function WorkspacePage() { setFocusedPane(activeTabId, nextPaneId); } }, - undefined, + hotkeyOptions, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], ); @@ -383,7 +403,7 @@ function WorkspacePage() { }); } }, [workspace?.worktreePath, resolvedDefaultApp, mutateOpenInApp, projectId]); - useAppHotkey("OPEN_IN_APP", handleOpenInApp, undefined, [handleOpenInApp]); + useAppHotkey("OPEN_IN_APP", handleOpenInApp, hotkeyOptions, [handleOpenInApp]); // Copy path shortcut const { copyToClipboard } = useCopyToClipboard(); @@ -394,7 +414,7 @@ function WorkspacePage() { copyToClipboard(workspace.worktreePath); } }, - undefined, + hotkeyOptions, [workspace?.worktreePath], ); @@ -412,7 +432,7 @@ function WorkspacePage() { createOrOpenPR(); } }, - undefined, + hotkeyOptions, [pr?.url, createOrOpenPR], ); @@ -431,13 +451,13 @@ function WorkspacePage() { commandPalette.handleOpenChange(false); keywordSearch.toggle(); }, [commandPalette.handleOpenChange, keywordSearch.toggle]); - useAppHotkey("QUICK_OPEN", handleQuickOpen, undefined, [handleQuickOpen]); - useAppHotkey("KEYWORD_SEARCH", handleKeywordSearch, undefined, [ + useAppHotkey("QUICK_OPEN", handleQuickOpen, hotkeyOptions, [handleQuickOpen]); + useAppHotkey("KEYWORD_SEARCH", handleKeywordSearch, hotkeyOptions, [ handleKeywordSearch, ]); // Toggle changes sidebar (⌘L) - useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), undefined, [ + useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), hotkeyOptions, [ toggleSidebar, ]); @@ -453,7 +473,7 @@ function WorkspacePage() { setSidebarMode(isExpanded ? SidebarMode.Tabs : SidebarMode.Changes); } }, - undefined, + hotkeyOptions, [isSidebarOpen, setSidebarOpen, setSidebarMode, currentSidebarMode], ); @@ -488,7 +508,7 @@ function WorkspacePage() { } } }, - undefined, + hotkeyOptions, [activeTabId, focusedPaneId, activeTab, splitPaneAuto, resolveSplitTarget], ); @@ -505,7 +525,7 @@ function WorkspacePage() { splitPaneVertical(activeTabId, target.paneId, target.path); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -528,7 +548,7 @@ function WorkspacePage() { splitPaneHorizontal(activeTabId, target.paneId, target.path); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -553,7 +573,7 @@ function WorkspacePage() { }); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -578,7 +598,7 @@ function WorkspacePage() { }); } }, - undefined, + hotkeyOptions, [ activeTabId, focusedPaneId, @@ -596,7 +616,7 @@ function WorkspacePage() { equalizePaneSplits(activeTabId); } }, - undefined, + hotkeyOptions, [activeTabId, equalizePaneSplits], ); @@ -614,7 +634,7 @@ function WorkspacePage() { navigateToWorkspace(prevWorkspaceId, navigate); } }, - undefined, + hotkeyOptions, [getPreviousWorkspace.data, navigate], ); @@ -631,11 +651,12 @@ function WorkspacePage() { navigateToWorkspace(nextWorkspaceId, navigate); } }, - undefined, + hotkeyOptions, [getNextWorkspace.data, navigate], ); return ( +
{showInitView ? ( @@ -646,6 +667,7 @@ function WorkspacePage() { /> ) : (
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx index 4bdae7e68c7..94257f6ac6f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx @@ -1,5 +1,5 @@ -import { useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useGitChangesStatus } from "renderer/screens/main/hooks/useGitChangesStatus"; import { RightSidebarTab, @@ -8,7 +8,7 @@ import { import { InfiniteScrollView } from "./components/InfiniteScrollView"; export function ChangesContent() { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const isChangesSidebarVisible = useSidebarStore( (s) => s.isSidebarOpen && s.rightSidebarTab === RightSidebarTab.Changes, ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index e6b3e785714..10e0e076684 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -1,10 +1,10 @@ import { Alert, AlertDescription, AlertTitle } from "@superset/ui/alert"; import { Button } from "@superset/ui/button"; import { Collapsible, CollapsibleContent } from "@superset/ui/collapsible"; -import { useParams } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuFileCode, LuLoader } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor"; import { FileSaveConflictDialog } from "renderer/screens/main/components/WorkspaceView/components/FileSaveConflictDialog"; import { useChangesStore } from "renderer/stores/changes"; @@ -79,7 +79,7 @@ export function FileDiffSection({ onDiscard, isActioning = false, }: FileDiffSectionProps) { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const sectionRef = useRef(null); const copyTimeoutRef = useRef | null>(null); const { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index ffd4a619a7d..619910a45e1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -1,5 +1,4 @@ import type { ExternalApp } from "@superset/local-db"; -import { useParams } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import type { IconType } from "react-icons"; import { BsTerminalPlus } from "react-icons/bs"; @@ -8,6 +7,7 @@ import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; import { getAppOption } from "renderer/components/OpenInExternalDropdown"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog"; import { useHotkeyDisplay } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -35,9 +35,7 @@ export function EmptyTabView({ onOpenInApp, onOpenQuickOpen, }: EmptyTabViewProps) { - const { workspaceId } = useParams({ - from: "/_authenticated/_dashboard/workspace/$workspaceId/", - }); + const workspaceId = useWorkspaceId(); const addChatTab = useTabsStore((s) => s.addChatTab); const addBrowserTab = useTabsStore((s) => s.addBrowserTab); const activeTheme = useTheme(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx index 3296797fc8f..ab19a9838ae 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -88,7 +88,9 @@ export function GroupItem({ return { type: MosaicDragType.WINDOW, item: { - mosaicId: canDropOntoActiveTab ? MOSAIC_ID : TAB_DRAG_NO_MATCH_ID, + mosaicId: canDropOntoActiveTab + ? `${MOSAIC_ID}-${activeTabId}` + : TAB_DRAG_NO_MATCH_ID, hideTimer: 0, tabId: tab.id, index, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 9f6a84e3195..a39be6d099e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -1,7 +1,7 @@ import type { TerminalPreset } from "@superset/local-db"; import { eq, or } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, @@ -12,6 +12,7 @@ import { } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { usePresets } from "renderer/react-query/presets"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { requestTabClose } from "renderer/stores/editor-state/editorCoordinator"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -31,7 +32,7 @@ import { GroupItem } from "./GroupItem"; const NO_WORKSPACE_MATCH = "__no_workspace__"; export function GroupStrip() { - const { workspaceId: activeWorkspaceId } = useParams({ strict: false }); + const activeWorkspaceId = useWorkspaceId(); const allTabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/PersistentTabRenderer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/PersistentTabRenderer.tsx new file mode 100644 index 00000000000..5fbe6c2b4d3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/PersistentTabRenderer.tsx @@ -0,0 +1,71 @@ +import { useMemo } from "react"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; +import { TabView } from "./TabView"; + +interface PersistentTabRendererProps { + tabs: Tab[]; + activeTabId: string | null; +} + +/** + * Renders workspace tabs, keeping only those that contain a browser (webview) + * pane mounted when inactive. Tabs without webviews are unmounted normally. + * + * Electron's tag reloads its content whenever it is reparented in the + * DOM. By keeping webview-containing tabs mounted (but off-screen), webview + * elements stay in their original DOM parent and never reparent, eliminating + * the reload. Non-webview tabs (terminals, chat, files) can safely unmount and + * remount without data loss. + */ +export function PersistentTabRenderer({ + tabs, + activeTabId, +}: PersistentTabRendererProps) { + const panes = useTabsStore((s) => s.panes); + + const tabsWithWebview = useMemo(() => { + const ids = new Set(); + for (const tab of tabs) { + const paneIds = extractPaneIdsFromLayout(tab.layout); + if (paneIds.some((id) => panes[id]?.type === "webview")) { + ids.add(tab.id); + } + } + return ids; + }, [tabs, panes]); + + return ( + <> + {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const hasWebview = tabsWithWebview.has(tab.id); + + // Tabs without webviews: only render when active (original behavior) + if (!hasWebview && !isActive) return null; + + return ( +
+ +
+ ); + })} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts index cd9363802dc..1412808a396 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -1,12 +1,27 @@ import { useCallback, useEffect, useRef } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { PLATFORM } from "shared/constants"; // --------------------------------------------------------------------------- // Module-level singletons // --------------------------------------------------------------------------- const webviewRegistry = new Map(); +/** + * A persistent wrapper div per pane that ALWAYS contains its webview. + * + * Electron's tag reloads its content whenever the element is + * reparented (moved from one parent to another). The previous approach moved + * the webview itself between a visible container and a hidden one — each move + * was a reparent that triggered a reload. + * + * By wrapping the webview in a persistent div and only ever moving that + * wrapper, the webview's parentNode never changes, so Electron never sees a + * reparent. The wrapper moves between React's container div (visible) and a + * hidden parking container, but the webview inside is untouched. + */ +const wrapperRegistry = new Map(); /** Tracks paneId → last-registered webContentsId so we can re-register if it changes. */ const registeredWebContentsIds = new Map(); let hiddenContainer: HTMLDivElement | null = null; @@ -58,6 +73,11 @@ window.addEventListener("drop", () => setWebviewsDragPassthrough(false), true); /** Call from useBrowserLifecycle when a pane is removed. */ export function destroyPersistentWebview(paneId: string): void { + const wrapper = wrapperRegistry.get(paneId); + if (wrapper) { + wrapper.remove(); + wrapperRegistry.delete(paneId); + } const webview = webviewRegistry.get(paneId); if (webview) { webview.remove(); @@ -171,19 +191,27 @@ export function usePersistentWebview({ [paneId], ); - // Main lifecycle effect: create or reclaim webview, attach events, park on unmount + // Main lifecycle effect: create or reclaim wrapper+webview, attach events, park on unmount useEffect(() => { const container = containerRef.current; if (!container) return; + let wrapper = wrapperRegistry.get(paneId); let webview = webviewRegistry.get(paneId); - if (webview) { - // Reclaim from hidden container - container.appendChild(webview); + if (wrapper && webview) { + // Reclaim: move the wrapper (with webview inside) into React's container. + // The webview's parentNode stays as `wrapper` — no reparent, no reload. + container.appendChild(wrapper); syncStoreFromWebview(webview); } else { - // Create new webview + // First time: create a persistent wrapper div and a webview inside it. + wrapper = document.createElement("div"); + wrapper.style.display = "flex"; + wrapper.style.flex = "1"; + wrapper.style.width = "100%"; + wrapper.style.height = "100%"; + webview = document.createElement("webview") as Electron.WebviewTag; webview.setAttribute("partition", "persist:superset"); webview.setAttribute("allowpopups", ""); @@ -193,8 +221,11 @@ export function usePersistentWebview({ webview.style.height = "100%"; webview.style.border = "none"; + // webview goes into wrapper, wrapper goes into container + wrapper.appendChild(webview); + wrapperRegistry.set(paneId, wrapper); webviewRegistry.set(paneId, webview); - container.appendChild(webview); + container.appendChild(wrapper); const finalUrl = sanitizeUrl(initialUrlRef.current); webview.src = finalUrl; @@ -207,11 +238,28 @@ export function usePersistentWebview({ const handleDomReady = () => { const webContentsId = wv.getWebContentsId(); const previousId = registeredWebContentsIds.get(paneId); - // Register on first load, or re-register if webContentsId changed (e.g. after DOM reparenting) + // Register on first load, or re-register if webContentsId changed if (previousId !== webContentsId) { registeredWebContentsIds.set(paneId, webContentsId); registerBrowser({ paneId, webContentsId }); } + + // Inject mouse back/forward button support into the guest page. + // Electron's consumes mouse events in the guest process, + // so the host renderer never sees button 3/4 (back/forward). + // Only needed on macOS — Windows/Linux use the `app-command` event + // handler in the main process instead. + if (PLATFORM.IS_MAC) { + wv.executeJavaScript(` + if (!window.__supersetMouseNavInstalled) { + window.__supersetMouseNavInstalled = true; + window.addEventListener('mouseup', function(e) { + if (e.button === 3) { e.preventDefault(); history.back(); } + if (e.button === 4) { e.preventDefault(); history.forward(); } + }, true); + } + `).catch(() => {}); + } }; const handleDidStartLoading = () => { @@ -340,7 +388,7 @@ export function usePersistentWebview({ ); wv.addEventListener("did-fail-load", handleDidFailLoad as EventListener); - // -- Cleanup: park in hidden container ----------------------------- + // -- Cleanup: park the wrapper (not the webview) in hidden container - return () => { wv.removeEventListener("dom-ready", handleDomReady); @@ -367,7 +415,13 @@ export function usePersistentWebview({ handleDidFailLoad as EventListener, ); - getHiddenContainer().appendChild(wv); + // Park the WRAPPER (which contains the webview) in the hidden + // container. The webview's parentNode remains `wrapper` throughout + // — no reparent, no reload. + const w = wrapperRegistry.get(paneId); + if (w) { + getHiddenContainer().appendChild(w); + } }; // paneId is stable for the lifetime of a pane; initialUrlRef only used on first create. }, [paneId, registerBrowser, syncStoreFromWebview, upsertHistory]); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index c3ed1de0ea7..cad87dec889 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -1,10 +1,10 @@ import { Alert, AlertDescription, AlertTitle } from "@superset/ui/alert"; import { Button } from "@superset/ui/button"; -import { useParams } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import type { MarkdownEditorAdapter } from "renderer/components/MarkdownRenderer"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { FileSaveConflictDialog } from "renderer/screens/main/components/WorkspaceView/components/FileSaveConflictDialog"; import { useWorkspaceFileEvents } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceFileEvents"; import { useChangesStore } from "renderer/stores/changes"; @@ -127,7 +127,7 @@ export function FileViewerPane({ onMoveToTab, onMoveToNewTab, }: FileViewerPaneProps) { - const { workspaceId } = useParams({ strict: false }); + const workspaceId = useWorkspaceId(); const normalizedWorkspaceId = workspaceId ?? worktreePath; const fileViewer = useTabsStore((s) => s.panes[paneId]?.fileViewer); const isFocused = useTabsStore((s) => s.focusedPaneIds[tabId] === paneId); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 7dd16399845..bf03841d4ea 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -295,7 +295,7 @@ export function TabView({ tab }: TabViewProps) { return (
- mosaicId={MOSAIC_ID} + mosaicId={`${MOSAIC_ID}-${tab.id}`} renderTile={renderPane} value={cleanedLayout} onChange={handleLayoutChange} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 77ecd4c0183..8b0bab01c77 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,23 +1,23 @@ import type { ExternalApp } from "@superset/local-db"; -import { useParams } from "@tanstack/react-router"; import { useEffect, useMemo, useRef } from "react"; import { useTabsStore } from "renderer/stores/tabs/store"; import { resolveActiveTabIdForWorkspace } from "renderer/stores/tabs/utils"; import { EmptyTabView } from "./EmptyTabView"; -import { TabView } from "./TabView"; +import { PersistentTabRenderer } from "./PersistentTabRenderer"; interface TabsContentProps { + workspaceId: string; defaultExternalApp?: ExternalApp | null; onOpenInApp: () => void; onOpenQuickOpen: () => void; } export function TabsContent({ + workspaceId: activeWorkspaceId, defaultExternalApp, onOpenInApp, onOpenQuickOpen, }: TabsContentProps) { - const { workspaceId: activeWorkspaceId } = useParams({ strict: false }); const allTabs = useTabsStore((s) => s.tabs); const activeTabIds = useTabsStore((s) => s.activeTabIds); const tabHistoryStacks = useTabsStore((s) => s.tabHistoryStacks); @@ -47,10 +47,13 @@ export function TabsContent({ return resolvedActiveTabId; }, [activeWorkspaceId, activeTabIds, allTabs, tabHistoryStacks]); - const tabToRender = useMemo(() => { - if (!activeTabId) return null; - return allTabs.find((tab) => tab.id === activeTabId) || null; - }, [activeTabId, allTabs]); + const workspaceTabs = useMemo( + () => + activeWorkspaceId + ? allTabs.filter((t) => t.workspaceId === activeWorkspaceId) + : [], + [activeWorkspaceId, allTabs], + ); useEffect(() => { const nextWorkspaceId = activeWorkspaceId ?? null; @@ -89,8 +92,11 @@ export function TabsContent({ return (
- {tabToRender ? ( - + {workspaceTabs.length > 0 ? ( + ) : ( void; onOpenQuickOpen: () => void; } export function ContentView({ + workspaceId, defaultExternalApp, onOpenInApp, onOpenQuickOpen, @@ -31,6 +33,7 @@ export function ContentView({ {showPresetsBar && } (null); + +export const WorkspaceIdProvider = WorkspaceIdContext.Provider; + +export function useWorkspaceId(): string { + const id = useContext(WorkspaceIdContext); + if (!id) { + throw new Error("useWorkspaceId must be used within a WorkspaceIdProvider"); + } + return id; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx index 94f8b5dabea..ef4b4684ceb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx @@ -13,12 +13,14 @@ import { useBrowserLifecycle } from "../hooks/useBrowserLifecycle"; import { RightSidebar } from "../RightSidebar"; interface WorkspaceLayoutProps { + workspaceId: string; defaultExternalApp?: ExternalApp | null; onOpenInApp: () => void; onOpenQuickOpen: () => void; } export function WorkspaceLayout({ + workspaceId, defaultExternalApp, onOpenInApp, onOpenQuickOpen, @@ -40,6 +42,7 @@ export function WorkspaceLayout({ ) : (