diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx index 295f7a91eaf..7daa5d33e0e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx @@ -14,7 +14,6 @@ import { ServiceStatusIndicators } from "./components/ServiceStatusIndicators"; import { SidebarToggle } from "./components/SidebarToggle"; import { V2WorkspaceOpenInButton } from "./components/V2WorkspaceOpenInButton"; import { V2WorkspaceSearchBarTrigger } from "./components/V2WorkspaceSearchBarTrigger"; -import { VersionToggle } from "./components/VersionToggle"; import { WindowControls } from "./components/WindowControls"; export function TopBar() { @@ -32,7 +31,7 @@ export function TopBar() { { enabled: !!workspaceId && !isV2WorkspaceRoute }, ); const isOnline = useOnlineStatus(); - const { isV2CloudEnabled, isRemoteV2Enabled } = useIsV2CloudEnabled(); + const { isV2CloudEnabled } = useIsV2CloudEnabled(); // Default to Mac layout while loading to avoid overlap with traffic lights const isMac = platform === undefined || platform === "darwin"; @@ -47,7 +46,6 @@ export function TopBar() { - {isRemoteV2Enabled && } {isV2WorkspaceRoute ? ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/VersionToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/VersionToggle.tsx deleted file mode 100644 index 63aebc654ae..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/VersionToggle.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; -import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; - -export function VersionToggle() { - const { forceV1, toggle } = useV2LocalOverrideStore(); - const activeVersion = forceV1 ? "v1" : "v2"; - - return ( - - - - - - {forceV1 - ? "Early Access: Switch to Superset V2" - : "Switch to Superset V1"} - - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/index.ts deleted file mode 100644 index 3e06c734ddd..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { VersionToggle } from "./VersionToggle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/index.ts new file mode 100644 index 00000000000..d0fa34260cd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/index.ts @@ -0,0 +1 @@ +export { useBrowserShellInteractionPassthrough } from "./useBrowserShellInteractionPassthrough"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/useBrowserShellInteractionPassthrough.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/useBrowserShellInteractionPassthrough.ts new file mode 100644 index 00000000000..ca2fdb0c855 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/useBrowserShellInteractionPassthrough.ts @@ -0,0 +1,64 @@ +import type { WorkspaceInteractionState } from "@superset/panes"; +import { useCallback, useEffect, useRef } from "react"; +import { browserRuntimeRegistry } from "../usePaneRegistry/components/BrowserPane"; + +interface UseBrowserShellInteractionPassthroughArgs { + sidebarOpen: boolean; +} + +export function useBrowserShellInteractionPassthrough({ + sidebarOpen, +}: UseBrowserShellInteractionPassthroughArgs) { + const workspaceResizeActiveRef = useRef(false); + const sidebarResizeActiveRef = useRef(false); + + const syncBrowserShellInteractionPassthrough = useCallback(() => { + browserRuntimeRegistry.setShellInteractionPassthrough( + workspaceResizeActiveRef.current || sidebarResizeActiveRef.current, + ); + }, []); + + const onWorkspaceInteractionStateChange = useCallback( + (state: WorkspaceInteractionState) => { + workspaceResizeActiveRef.current = state.resizeActive; + syncBrowserShellInteractionPassthrough(); + }, + [syncBrowserShellInteractionPassthrough], + ); + + const onSidebarResizeDragging = useCallback( + (isDragging: boolean) => { + sidebarResizeActiveRef.current = isDragging; + syncBrowserShellInteractionPassthrough(); + }, + [syncBrowserShellInteractionPassthrough], + ); + + const clearBrowserShellInteractionPassthrough = useCallback(() => { + workspaceResizeActiveRef.current = false; + sidebarResizeActiveRef.current = false; + browserRuntimeRegistry.setShellInteractionPassthrough(false); + }, []); + + useEffect(() => { + window.addEventListener("blur", clearBrowserShellInteractionPassthrough); + return () => { + window.removeEventListener( + "blur", + clearBrowserShellInteractionPassthrough, + ); + clearBrowserShellInteractionPassthrough(); + }; + }, [clearBrowserShellInteractionPassthrough]); + + useEffect(() => { + if (sidebarOpen || !sidebarResizeActiveRef.current) return; + sidebarResizeActiveRef.current = false; + syncBrowserShellInteractionPassthrough(); + }, [sidebarOpen, syncBrowserShellInteractionPassthrough]); + + return { + onSidebarResizeDragging, + onWorkspaceInteractionStateChange, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/index.ts new file mode 100644 index 00000000000..c9c5b0b213f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/index.ts @@ -0,0 +1 @@ +export { useClearActivePaneAttention } from "./useClearActivePaneAttention"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts new file mode 100644 index 00000000000..800b0d1eac0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts @@ -0,0 +1,36 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useEffect } from "react"; +import { + getV2NotificationSourcesForPane, + useV2NotificationStore, + useV2PaneNotificationStatus, +} from "renderer/stores/v2-notifications"; +import { useStore } from "zustand"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData } from "../../types"; + +export function useClearActivePaneAttention({ + workspaceId, + store, +}: { + workspaceId: string; + store: StoreApi>; +}): void { + const activePane = useStore(store, (state) => { + const tab = state.tabs.find( + (candidate) => candidate.id === state.activeTabId, + ); + return tab?.activePaneId ? tab.panes[tab.activePaneId] : undefined; + }); + const activePaneStatus = useV2PaneNotificationStatus(workspaceId, activePane); + const clearSourceAttention = useV2NotificationStore( + (state) => state.clearSourceAttention, + ); + + useEffect(() => { + if (activePaneStatus !== "review") return; + for (const source of getV2NotificationSourcesForPane(activePane)) { + clearSourceAttention(source, workspaceId); + } + }, [activePane, activePaneStatus, clearSourceAttention, workspaceId]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/index.ts new file mode 100644 index 00000000000..bced12af430 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/index.ts @@ -0,0 +1 @@ +export { useDefaultPaneActions } from "./useDefaultPaneActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/useDefaultPaneActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/useDefaultPaneActions.tsx new file mode 100644 index 00000000000..5699b313ca8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/useDefaultPaneActions.tsx @@ -0,0 +1,40 @@ +import type { PaneActionConfig } from "@superset/panes"; +import { useMemo } from "react"; +import { HiMiniXMark } from "react-icons/hi2"; +import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; +import { HotkeyLabel } from "renderer/hotkeys"; +import type { PaneViewerData, TerminalPaneData } from "../../types"; + +export function useDefaultPaneActions(): PaneActionConfig[] { + return useMemo[]>( + () => [ + { + key: "split", + icon: (ctx) => + ctx.pane.parentDirection === "horizontal" ? ( + + ) : ( + + ), + tooltip: , + onClick: (ctx) => { + const position = + ctx.pane.parentDirection === "horizontal" ? "down" : "right"; + ctx.actions.split(position, { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + } as TerminalPaneData, + }); + }, + }, + { + key: "close", + icon: , + tooltip: , + onClick: (ctx) => ctx.actions.close(), + }, + ], + [], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/index.ts new file mode 100644 index 00000000000..2809604a41a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/index.ts @@ -0,0 +1 @@ +export { useDirtyTabCloseGuard } from "./useDirtyTabCloseGuard"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/useDirtyTabCloseGuard.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/useDirtyTabCloseGuard.ts new file mode 100644 index 00000000000..01ea5cc54e0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/useDirtyTabCloseGuard.ts @@ -0,0 +1,76 @@ +import type { WorkspaceProps } from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; +import { useCallback } from "react"; +import { getBaseName } from "renderer/lib/pathBasename"; +import { getDocument } from "../../state/fileDocumentStore"; +import type { FilePaneData, PaneViewerData } from "../../types"; + +type OnBeforeCloseTab = NonNullable< + WorkspaceProps["onBeforeCloseTab"] +>; + +export function useDirtyTabCloseGuard({ + workspaceId, +}: { + workspaceId: string; +}): OnBeforeCloseTab { + return useCallback( + (tab) => { + const dirtyPanes = Object.values(tab.panes).filter((pane) => { + if (pane.kind !== "file") return false; + const filePath = (pane.data as FilePaneData).filePath; + return getDocument(workspaceId, filePath)?.dirty === true; + }); + const dirtyFileNames = dirtyPanes.map((pane) => + getBaseName((pane.data as FilePaneData).filePath), + ); + if (dirtyPanes.length === 0) return true; + const title = + dirtyPanes.length === 1 + ? `Do you want to save the changes you made to ${dirtyFileNames[0]}?` + : `Do you want to save changes to ${dirtyPanes.length} files?`; + return new Promise((resolve) => { + alert({ + title, + description: "Your changes will be lost if you don't save them.", + actions: [ + { + label: "Save All", + onClick: async () => { + for (const pane of dirtyPanes) { + const filePath = (pane.data as FilePaneData).filePath; + const doc = getDocument(workspaceId, filePath); + if (!doc) continue; + const result = await doc.save(); + if (result.status !== "saved") { + resolve(false); + return; + } + } + resolve(true); + }, + }, + { + label: "Don't Save", + variant: "secondary", + onClick: async () => { + for (const pane of dirtyPanes) { + const filePath = (pane.data as FilePaneData).filePath; + const doc = getDocument(workspaceId, filePath); + if (doc) await doc.reload(); + } + resolve(true); + }, + }, + { + label: "Cancel", + variant: "ghost", + onClick: () => resolve(false), + }, + ], + }); + }); + }, + [workspaceId], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index ae92837f7bb..364da1c8bb3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -73,6 +73,8 @@ class BrowserRuntimeRegistryImpl { private tabsSnapshots = new Map(); private rootContainer: HTMLDivElement | null = null; private globalListenersInstalled = false; + private windowDragPassthrough = false; + private shellInteractionPassthrough = false; private getStateListeners(paneId: string): Set<() => void> { let set = this.stateListenersByPaneId.get(paneId); @@ -99,12 +101,16 @@ class BrowserRuntimeRegistryImpl { } private ensureRootContainer(): HTMLDivElement { - if (this.rootContainer?.isConnected) return this.rootContainer; + if (this.rootContainer?.isConnected) { + this.installGlobalListeners(); + return this.rootContainer; + } const existing = document.getElementById( ROOT_CONTAINER_ID, ) as HTMLDivElement | null; if (existing) { this.rootContainer = existing; + this.installGlobalListeners(); return existing; } const root = document.createElement("div"); @@ -125,17 +131,25 @@ class BrowserRuntimeRegistryImpl { private installGlobalListeners() { if (this.globalListenersInstalled) return; this.globalListenersInstalled = true; - const setPassthrough = (passthrough: boolean) => { - for (const group of this.groups.values()) { - if (!group.visible) continue; - const active = group.tabs.find((t) => t.tabId === group.activeTabId); - if (!active) continue; - active.webview.style.pointerEvents = passthrough ? "none" : "auto"; - } - }; - window.addEventListener("dragstart", () => setPassthrough(true), true); - window.addEventListener("dragend", () => setPassthrough(false), true); - window.addEventListener("drop", () => setPassthrough(false), true); + + // FORK NOTE: upstream refactored inline setPassthrough to setWindowDragPassthrough method. + // We adopt the method-based approach while keeping fork's multi-tab/group structure intact. + window.addEventListener( + "dragstart", + () => this.setWindowDragPassthrough(true), + true, + ); + window.addEventListener( + "dragend", + () => this.setWindowDragPassthrough(false), + true, + ); + window.addEventListener( + "drop", + () => this.setWindowDragPassthrough(false), + true, + ); + window.addEventListener("blur", () => this.setWindowDragPassthrough(false)); window.addEventListener("resize", () => { for (const group of this.groups.values()) { if (group.placeholder) this.applyLayout(group); @@ -143,6 +157,43 @@ class BrowserRuntimeRegistryImpl { }); } + // FORK NOTE: upstream uses setWindowDragPassthrough / setShellInteractionPassthrough + // with a single-entry registry (RegistryEntry). Fork uses a multi-tab/group structure + // (PaneGroup with TabEntry[]). We port upstream's passthrough concept to apply across + // all visible active tabs in the fork's group model. + private setWindowDragPassthrough(passthrough: boolean) { + const wasActive = this.isPointerPassthroughActive(); + this.windowDragPassthrough = passthrough; + this.applyPointerPassthroughIfChanged(wasActive); + } + + setShellInteractionPassthrough(passthrough: boolean): void { + const wasActive = this.isPointerPassthroughActive(); + this.shellInteractionPassthrough = passthrough; + this.applyPointerPassthroughIfChanged(wasActive); + } + + private isPointerPassthroughActive() { + return this.windowDragPassthrough || this.shellInteractionPassthrough; + } + + private applyPointerPassthroughIfChanged(wasActive: boolean) { + const isActive = this.isPointerPassthroughActive(); + if (wasActive !== isActive) this.applyPointerPassthrough(); + } + + // FORK NOTE: upstream's applyPointerPassthrough iterates over this.entries (single-entry model). + // Fork iterates over groups and applies only to the active tab of each visible group. + private applyPointerPassthrough() { + const passthrough = this.isPointerPassthroughActive(); + for (const group of this.groups.values()) { + if (!group.visible) continue; + const active = group.tabs.find((t) => t.tabId === group.activeTabId); + if (!active) continue; + active.webview.style.pointerEvents = passthrough ? "none" : "auto"; + } + } + private applyLayout(group: PaneGroup) { if (!group.placeholder) return; const rect = group.placeholder.getBoundingClientRect(); @@ -163,7 +214,10 @@ class BrowserRuntimeRegistryImpl { w.style.height = `${rect.height}px`; w.style.opacity = "1"; w.style.zIndex = "100"; - w.style.pointerEvents = "auto"; + // Respect passthrough state when making active tab interactive + w.style.pointerEvents = this.isPointerPassthroughActive() + ? "none" + : "auto"; } else { w.style.top = "-100000px"; w.style.left = "-100000px"; @@ -521,6 +575,9 @@ class BrowserRuntimeRegistryImpl { const observer = new ResizeObserver(() => this.applyLayout(groupRef)); observer.observe(placeholder); group.resizeObserver = observer; + // FORK NOTE: upstream calls updateLayout(entry) + applyPointerPassthrough() separately. + // Fork uses applyLayout(group) which handles all tabs in the group and also applies + // pointer passthrough state via isPointerPassthroughActive() check within applyLayout. this.applyLayout(group); this.notifyTabs(paneId); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/index.ts new file mode 100644 index 00000000000..b151bd7f0ef --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/index.ts @@ -0,0 +1 @@ +export { useWorkspaceFileNavigation } from "./useWorkspaceFileNavigation"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/useWorkspaceFileNavigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/useWorkspaceFileNavigation.ts new file mode 100644 index 00000000000..9f5eb8910de --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/useWorkspaceFileNavigation.ts @@ -0,0 +1,161 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { V2UserPreferencesApi } from "renderer/hooks/useV2UserPreferences"; +import { + toAbsoluteWorkspacePath, + toRelativeWorkspacePath, +} from "shared/absolute-paths"; +import { useStore } from "zustand"; +import type { StoreApi } from "zustand/vanilla"; +import type { FilePaneData, PaneViewerData } from "../../types"; +import { + type RecentFile, + useRecentlyViewedFiles, +} from "../useRecentlyViewedFiles"; + +interface PendingReveal { + path: string; + isDirectory: boolean; +} + +export function useWorkspaceFileNavigation({ + workspaceId, + store, + setRightSidebarOpen, + setRightSidebarTab, +}: { + workspaceId: string; + store: StoreApi>; + setRightSidebarOpen: V2UserPreferencesApi["setRightSidebarOpen"]; + setRightSidebarTab: V2UserPreferencesApi["setRightSidebarTab"]; +}): { + openFilePane: (filePath: string, openInNewTab?: boolean) => void; + revealPath: ( + path: string, + options?: { + isDirectory?: boolean; + }, + ) => void; + selectedFilePath: string | undefined; + pendingReveal: PendingReveal | null; + recentFiles: RecentFile[]; + openFilePaths: Set; +} { + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const worktreePath = workspaceQuery.data?.worktreePath ?? ""; + + const { recentFiles, recordView } = useRecentlyViewedFiles(workspaceId); + + const activeFilePanePath = useStore(store, (state) => { + const tab = state.tabs.find( + (candidate) => candidate.id === state.activeTabId, + ); + if (!tab?.activePaneId) return undefined; + const pane = tab.panes[tab.activePaneId]; + if (pane?.kind === "file") return (pane.data as FilePaneData).filePath; + return undefined; + }); + + const [selectedFilePath, setSelectedFilePath] = useState( + activeFilePanePath, + ); + // Every reveal request is a fresh object, so the FilesTab effect keyed on + // `pendingReveal` re-runs even when the path is the same (for example, the + // user collapsed a folder and re-requested it from the terminal). + const [pendingReveal, setPendingReveal] = useState( + null, + ); + + useEffect(() => { + if (activeFilePanePath !== undefined) { + setSelectedFilePath(activeFilePanePath); + setPendingReveal({ path: activeFilePanePath, isDirectory: false }); + } + }, [activeFilePanePath]); + + const openFilePathsKey = useStore(store, (state) => + state.tabs + .flatMap((tab) => + Object.values(tab.panes) + .filter((pane) => pane.kind === "file") + .map((pane) => (pane.data as FilePaneData).filePath), + ) + .join("\u0000"), + ); + const openFilePaths = useMemo( + () => new Set(openFilePathsKey ? openFilePathsKey.split("\u0000") : []), + [openFilePathsKey], + ); + + const openFilePane = useCallback( + (filePath: string, openInNewTab?: boolean) => { + const absoluteFilePath = worktreePath + ? toAbsoluteWorkspacePath(worktreePath, filePath) + : filePath; + if (worktreePath) { + const relativePath = toRelativeWorkspacePath( + worktreePath, + absoluteFilePath, + ); + if (relativePath && relativePath !== ".") { + recordView({ relativePath, absolutePath: absoluteFilePath }); + } + } + const state = store.getState(); + if (openInNewTab) { + state.addTab({ + panes: [ + { + kind: "file", + data: { + filePath: absoluteFilePath, + mode: "editor", + } as FilePaneData, + }, + ], + }); + return; + } + const active = state.getActivePane(); + if ( + active?.pane.kind === "file" && + (active.pane.data as FilePaneData).filePath === absoluteFilePath + ) { + state.setPanePinned({ paneId: active.pane.id, pinned: true }); + return; + } + state.openPane({ + pane: { + kind: "file", + data: { + filePath: absoluteFilePath, + mode: "editor", + } as FilePaneData, + }, + }); + }, + [store, worktreePath, recordView], + ); + + const revealPath = useCallback( + (path: string, options?: { isDirectory?: boolean }) => { + setRightSidebarOpen(true); + setRightSidebarTab("files"); + setSelectedFilePath(path); + setPendingReveal({ path, isDirectory: options?.isDirectory === true }); + }, + [setRightSidebarOpen, setRightSidebarTab], + ); + + return { + openFilePane, + revealPath, + selectedFilePath, + pendingReveal, + recentFiles, + openFilePaths, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/index.ts new file mode 100644 index 00000000000..869f49ebfc2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/index.ts @@ -0,0 +1 @@ +export { useWorkspacePaneOpeners } from "./useWorkspacePaneOpeners"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts new file mode 100644 index 00000000000..955c2de7723 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts @@ -0,0 +1,141 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useCallback } from "react"; +import type { StoreApi } from "zustand/vanilla"; +import type { + BrowserPaneData, + ChatPaneData, + CommentPaneData, + DiffPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +export function useWorkspacePaneOpeners({ + store, +}: { + store: StoreApi>; +}): { + openDiffPane: (filePath: string, openInNewTab?: boolean) => void; + addTerminalTab: () => void; + addChatTab: () => void; + addBrowserTab: () => void; + openCommentPane: (comment: CommentPaneData) => void; +} { + const openDiffPane = useCallback( + (filePath: string, openInNewTab?: boolean) => { + const state = store.getState(); + if (openInNewTab) { + state.addTab({ + panes: [ + { + kind: "diff", + data: { + path: filePath, + collapsedFiles: [], + } as DiffPaneData, + }, + ], + }); + return; + } + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "diff") continue; + const prev = pane.data as DiffPaneData; + state.setPaneData({ + paneId: pane.id, + data: { + ...prev, + path: filePath, + } as PaneViewerData, + }); + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + state.openPane({ + pane: { + kind: "diff", + data: { + path: filePath, + collapsedFiles: [], + } as DiffPaneData, + }, + }); + }, + [store], + ); + + const addTerminalTab = useCallback(() => { + store.getState().addTab({ + panes: [ + { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + } as TerminalPaneData, + }, + ], + }); + }, [store]); + + const addChatTab = useCallback(() => { + store.getState().addTab({ + panes: [ + { + kind: "chat", + data: { sessionId: null } as ChatPaneData, + }, + ], + }); + }, [store]); + + const addBrowserTab = useCallback(() => { + store.getState().addTab({ + panes: [ + { + kind: "browser", + data: { + url: "about:blank", + } as BrowserPaneData, + }, + ], + }); + }, [store]); + + const openCommentPane = useCallback( + (comment: CommentPaneData) => { + const state = store.getState(); + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "comment") continue; + state.setPaneData({ + paneId: pane.id, + data: comment as PaneViewerData, + }); + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + state.addTab({ + panes: [ + { + kind: "comment", + data: comment as PaneViewerData, + }, + ], + }); + }, + [store], + ); + + return { + openDiffPane, + addTerminalTab, + addChatTab, + addBrowserTab, + openCommentPane, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 086ffcab8da..4bacfbccecb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -3,14 +3,19 @@ import { type PaneActionConfig, type SplitPath, Workspace, - type WorkspaceStore, } from "@superset/panes"; -import { alert } from "@superset/ui/atoms/Alert"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@superset/ui/resizable"; +// FORK NOTE: fork retains the full import set including fork-only imports +// (toast, workspaceTrpc, useNavigate, HiMiniXMark, TbLayout*, useRightSidebarOpenViewWidth, +// addBrowserShortcutListener, dispatchBrowserShortcutEvent, electronTrpc, getBaseName, +// createWorkspaceMemo, useCommandPalette, getV2NotificationSourcesForPane, +// useV2NotificationStore, useV2PaneNotificationStatus, toAbsoluteWorkspacePath, +// toRelativeWorkspacePath, useStore, StoreApi). +// Upstream reduced imports as part of the refactor but fork cannot drop these. import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; @@ -26,45 +31,46 @@ import { dispatchBrowserShortcutEvent, } from "renderer/lib/browser-shortcut-events"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getBaseName } from "renderer/lib/pathBasename"; import { createWorkspaceMemo } from "renderer/lib/workspace-memos"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { CommandPalette, useCommandPalette, } from "renderer/screens/main/components/CommandPalette"; -import { - getV2NotificationSourcesForPane, - getV2NotificationSourcesForTab, - useV2NotificationStore, - useV2PaneNotificationStatus, -} from "renderer/stores/v2-notifications"; +import { getV2NotificationSourcesForTab } from "renderer/stores/v2-notifications"; import { toAbsoluteWorkspacePath, toRelativeWorkspacePath, } from "shared/absolute-paths"; import { useStore } from "zustand"; -import type { StoreApi } from "zustand/vanilla"; import { WorkspaceNotFoundState } from "../components/WorkspaceNotFoundState"; import { AddTabMenu } from "./components/AddTabMenu"; import { V2NotificationStatusIndicator } from "./components/V2NotificationStatusIndicator"; import { V2PresetsBar } from "./components/V2PresetsBar"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; +import { useBrowserShellInteractionPassthrough } from "./hooks/useBrowserShellInteractionPassthrough"; +import { useClearActivePaneAttention } from "./hooks/useClearActivePaneAttention"; import { useConsumeAutomationRunLink } from "./hooks/useConsumeAutomationRunLink"; import { useConsumeOpenUrlRequest } from "./hooks/useConsumeOpenUrlRequest"; import { useConsumePendingLaunch } from "./hooks/useConsumePendingLaunch"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; +// FORK NOTE: useDefaultPaneActions was added by upstream (b1e1eb742) to replace inline defaultPaneActions. +// Fork keeps the inline useMemo implementation in WorkspaceContent to preserve fork-specific split behavior. +// import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; +import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; +// FORK NOTE: useWorkspaceFileNavigation and useWorkspacePaneOpeners are imported by upstream (b1e1eb742) +// but fork keeps inline implementations. These imports are kept to avoid import errors +// if any indirect dependency uses them, but they are not called directly. +// import { useWorkspaceFileNavigation } from "./hooks/useWorkspaceFileNavigation"; +// import { useWorkspacePaneOpeners } from "./hooks/useWorkspacePaneOpeners"; import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; -import { - FileDocumentStoreProvider, - getDocument, -} from "./state/fileDocumentStore"; +import { FileDocumentStoreProvider } from "./state/fileDocumentStore"; import type { BrowserPaneData, ChatPaneData, @@ -189,37 +195,6 @@ function V2WorkspacePage() { ); } -/** - * Clear post-completion attention only for the pane the user is actually - * viewing. Clearing every review status on route entry would drop background - * tab attention before the user has looked at that pane. - */ -function useClearActivePaneAttention({ - workspaceId, - store, -}: { - workspaceId: string; - store: StoreApi>; -}): void { - const activePane = useStore(store, (state) => { - const tab = state.tabs.find( - (candidate) => candidate.id === state.activeTabId, - ); - return tab?.activePaneId ? tab.panes[tab.activePaneId] : undefined; - }); - const activePaneStatus = useV2PaneNotificationStatus(workspaceId, activePane); - const clearSourceAttention = useV2NotificationStore( - (state) => state.clearSourceAttention, - ); - - useEffect(() => { - if (activePaneStatus !== "review") return; - for (const source of getV2NotificationSourcesForPane(activePane)) { - clearSourceAttention(source, workspaceId); - } - }, [activePane, activePaneStatus, clearSourceAttention, workspaceId]); -} - function WorkspaceContent({ projectId, workspaceId, @@ -287,6 +262,12 @@ function WorkspaceContent({ requestId: openUrlRequestId, }); + // FORK NOTE: upstream introduced useWorkspaceFileNavigation (b1e1eb742) with openFilePane(filePath, openInNewTab?) + // and revealPath using setRightSidebarOpen/setRightSidebarTab from useV2UserPreferences. + // Fork keeps the inline implementation below because: + // 1. fork's openFilePane is a 3-argument variant (filePath, displayName?, location?) for memo tab titles + // 2. fork's revealPath uses collections.v2WorkspaceLocalState.update (per-workspace state) + // rather than v2UserPreferences (global state) const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ id: workspaceId, }); @@ -517,6 +498,10 @@ function WorkspaceContent({ onRevealPath: revealPath, }); const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); + // FORK NOTE: fork keeps openDiffPane, addTerminalTab, addChatTab, addBrowserTab, + // addMemoTab, openCommentPane inline rather than moving them to useWorkspacePaneOpeners. + // This is because fork has additional logic (addMemoTab, fork's openDiffPane variant, etc.) + // that upstream's useWorkspacePaneOpeners does not include. const openDiffPane = useCallback( (filePath: string, openInNewTab?: boolean) => { @@ -706,8 +691,16 @@ function WorkspaceContent({ ], [], ); + // FORK NOTE: upstream moved the dirty-tab close guard logic to useDirtyTabCloseGuard hook (b1e1eb742). + // Fork adopts the new hook call instead of keeping the inline implementation. + const onBeforeCloseTab = useDirtyTabCloseGuard({ workspaceId }); + // FORK NOTE: fork tracks rightSidebarOpen via localWorkspaceState (per-workspace persisted state) + // rather than upstream's v2UserPreferences. Both expose the same boolean; we use fork's source + // to avoid breaking the existing rightSidebarOpen persistence mechanism. const sidebarOpen = localWorkspaceState?.rightSidebarOpen ?? false; + const { onSidebarResizeDragging, onWorkspaceInteractionStateChange } = + useBrowserShellInteractionPassthrough({ sidebarOpen }); useWorkspaceHotkeys({ store, @@ -796,72 +789,15 @@ function WorkspaceContent({ onOpenTerminal={addTerminalTab} /> )} - onBeforeCloseTab={(tab) => { - const dirtyPanes = Object.values(tab.panes).filter((p) => { - if (p.kind !== "file") return false; - const filePath = (p.data as FilePaneData).filePath; - return getDocument(workspaceId, filePath)?.dirty === true; - }); - const dirtyFileNames = dirtyPanes.map((p) => - getBaseName((p.data as FilePaneData).filePath), - ); - if (dirtyPanes.length === 0) return true; - const title = - dirtyPanes.length === 1 - ? `Do you want to save the changes you made to ${dirtyFileNames[0]}?` - : `Do you want to save changes to ${dirtyPanes.length} files?`; - return new Promise((resolve) => { - alert({ - title, - description: - "Your changes will be lost if you don't save them.", - actions: [ - { - label: "Save All", - onClick: async () => { - for (const pane of dirtyPanes) { - const filePath = (pane.data as FilePaneData) - .filePath; - const doc = getDocument(workspaceId, filePath); - if (!doc) continue; - const result = await doc.save(); - if (result.status !== "saved") { - resolve(false); - return; - } - } - resolve(true); - }, - }, - { - label: "Don't Save", - variant: "secondary", - onClick: async () => { - for (const pane of dirtyPanes) { - const filePath = (pane.data as FilePaneData) - .filePath; - const doc = getDocument(workspaceId, filePath); - if (doc) await doc.reload(); - } - resolve(true); - }, - }, - { - label: "Cancel", - variant: "ghost", - onClick: () => resolve(false), - }, - ], - }); - }); - }} + onBeforeCloseTab={onBeforeCloseTab} + onInteractionStateChange={onWorkspaceInteractionStateChange} store={store} /> {sidebarOpen && ( <> - + , macOnly: true, }, + { + id: "/settings/experimental", + section: "experimental", + label: "Experimental", + icon: , + }, ], }, ]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx new file mode 100644 index 00000000000..90c4da1646d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx @@ -0,0 +1,61 @@ +import { Label } from "@superset/ui/label"; +import { Switch } from "@superset/ui/switch"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; +import { + isItemVisible, + SETTING_ITEM_ID, + type SettingItemId, +} from "../../../utils/settings-search"; + +interface ExperimentalSettingsProps { + visibleItems?: SettingItemId[] | null; +} + +export function ExperimentalSettings({ + visibleItems, +}: ExperimentalSettingsProps) { + const showSupersetV2 = isItemVisible( + SETTING_ITEM_ID.EXPERIMENTAL_SUPERSET_V2, + visibleItems, + ); + const { isV2CloudEnabled, isRemoteV2Enabled } = useIsV2CloudEnabled(); + const setForceV1 = useV2LocalOverrideStore((state) => state.setForceV1); + + return ( +
+
+

Experimental

+

+ Try early access features and previews +

+
+ +
+ {showSupersetV2 && ( +
+
+ +

+ Use the new workspace experience when early access is available +

+ {!isRemoteV2Enabled && ( +

+ Early access is not enabled for this account. +

+ )} +
+ setForceV1(!enabled)} + disabled={!isRemoteV2Enabled} + /> +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/index.ts new file mode 100644 index 00000000000..7f3499d9e21 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/index.ts @@ -0,0 +1 @@ +export { ExperimentalSettings } from "./ExperimentalSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/page.tsx new file mode 100644 index 00000000000..f03700a391b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/page.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { getMatchingItemsForSection } from "../utils/settings-search"; +import { ExperimentalSettings } from "./components/ExperimentalSettings"; + +export const Route = createFileRoute("/_authenticated/settings/experimental/")({ + component: ExperimentalSettingsPage, +}); + +function ExperimentalSettingsPage() { + const searchQuery = useSettingsSearchQuery(); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "experimental").map( + (item) => item.id, + ); + }, [searchQuery]); + + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index ba2e2a63433..96ea3b29272 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -46,6 +46,7 @@ const SECTION_ORDER: SettingsSection[] = [ "metrics", "security", "permissions", + "experimental", ]; function getSectionFromPath(pathname: string): SettingsSection | null { @@ -60,6 +61,7 @@ function getSectionFromPath(pathname: string): SettingsSection | null { if (pathname.includes("/settings/terminal")) return "terminal"; if (pathname.includes("/settings/links")) return "links"; if (pathname.includes("/settings/models")) return "models"; + if (pathname.includes("/settings/experimental")) return "experimental"; if (pathname.includes("/settings/integrations")) return "integrations"; if (pathname.includes("/settings/vscode-extensions")) return "vscodeExtensions"; @@ -96,6 +98,8 @@ function getPathFromSection(section: SettingsSection): string { return "/settings/links"; case "models": return "/settings/models"; + case "experimental": + return "/settings/experimental"; case "integrations": return "/settings/integrations"; case "extensions": diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 6c091502ad2..7f288fdc4d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -62,6 +62,8 @@ export const SETTING_ITEM_ID = { MODELS_OPENAI: "models-openai", MODELS_NEXT_EDIT: "models-next-edit", + EXPERIMENTAL_SUPERSET_V2: "experimental-superset-v2", + INTEGRATIONS_LINEAR: "integrations-linear", INTEGRATIONS_GITHUB: "integrations-github", INTEGRATIONS_SLACK: "integrations-slack", @@ -978,6 +980,28 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "mercury coder", ], }, + // FORK NOTE: upstream added EXPERIMENTAL_SUPERSET_V2 entry (869386993). + // Fork retains the entry alongside the existing MODELS_NEXT_EDIT entry. + { + id: SETTING_ITEM_ID.EXPERIMENTAL_SUPERSET_V2, + section: "experimental", + title: "Try Superset Version 2 (Early Access)", + description: "Switch between Superset V1 and the new V2 experience", + keywords: [ + "experimental", + "experiments", + "v2", + "v1", + "version", + "early access", + "beta", + "preview", + "workspace", + "workspaces", + "toggle", + "switch", + ], + }, { id: SETTING_ITEM_ID.INTEGRATIONS_LINEAR, section: "integrations", diff --git a/apps/desktop/src/renderer/stores/settings-state.ts b/apps/desktop/src/renderer/stores/settings-state.ts index 5cf66f4c593..1e38c49b331 100644 --- a/apps/desktop/src/renderer/stores/settings-state.ts +++ b/apps/desktop/src/renderer/stores/settings-state.ts @@ -14,6 +14,7 @@ export type SettingsSection = | "terminal" | "links" | "models" + | "experimental" | "integrations" | "extensions" | "billing" diff --git a/apps/desktop/src/renderer/stores/v2-local-override.ts b/apps/desktop/src/renderer/stores/v2-local-override.ts index 01b66fb9885..c815a605aa2 100644 --- a/apps/desktop/src/renderer/stores/v2-local-override.ts +++ b/apps/desktop/src/renderer/stores/v2-local-override.ts @@ -4,15 +4,15 @@ import { devtools, persist } from "zustand/middleware"; interface V2LocalOverrideState { /** When true, forces v1 mode locally even though v2 is enabled remotely. */ forceV1: boolean; - toggle: () => void; + setForceV1: (forceV1: boolean) => void; } export const useV2LocalOverrideStore = create()( devtools( persist( - (set, get) => ({ + (set) => ({ forceV1: false, - toggle: () => set({ forceV1: !get().forceV1 }), + setForceV1: (forceV1) => set({ forceV1 }), }), { name: "v2-local-override" }, ), diff --git a/packages/panes/src/index.ts b/packages/panes/src/index.ts index a4070aa2dcf..b4486b35b63 100644 --- a/packages/panes/src/index.ts +++ b/packages/panes/src/index.ts @@ -15,6 +15,7 @@ export type { PaneRegistry, RendererContext, TabContext, + WorkspaceInteractionState, WorkspaceProps, } from "./react"; export { resolveTabTitle, Workspace } from "./react"; diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index f0d3a72dd87..b04d4d254e1 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -5,6 +5,7 @@ import type { Pane } from "../../../types"; import type { WorkspaceProps } from "../../types"; import { Tab } from "./components/Tab"; import { TabBar } from "./components/TabBar"; +import { useWorkspaceInteractionState } from "./hooks/useWorkspaceInteractionState"; import { resolveTabTitle } from "./utils/resolveTabTitle"; export function Workspace({ @@ -17,12 +18,16 @@ export function Workspace({ renderAddTabMenu, renderBelowTabBar, onBeforeCloseTab, + onInteractionStateChange, paneActions, contextMenuActions, }: WorkspaceProps) { const tabs = useStore(store, (s) => s.tabs); const activeTabId = useStore(store, (s) => s.activeTabId); const activeTab = tabs.find((t) => t.id === activeTabId) ?? null; + const { onSplitResizeDragging } = useWorkspaceInteractionState({ + onInteractionStateChange, + }); const previousPanesRef = useRef>>(new Map()); useEffect(() => { @@ -91,6 +96,7 @@ export function Workspace({ registry={registry} paneActions={paneActions} contextMenuActions={contextMenuActions} + onSplitResizeDragging={onSplitResizeDragging} /> ) : (
diff --git a/packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx b/packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx index d4e52a2eeaf..7a8065bd18e 100644 --- a/packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx +++ b/packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx @@ -3,7 +3,7 @@ import { ResizablePanel, ResizablePanelGroup, } from "@superset/ui/resizable"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import type { StoreApi } from "zustand/vanilla"; import type { WorkspaceStore } from "../../../../../core/store"; import type { @@ -30,6 +30,7 @@ interface TabProps { contextMenuActions?: | ContextMenuActionConfig[] | ((context: RendererContext) => ContextMenuActionConfig[]); + onSplitResizeDragging?: (sourceId: string, isDragging: boolean) => void; } function SplitView({ @@ -40,6 +41,7 @@ function SplitView({ registry, paneActions, contextMenuActions, + onSplitResizeDragging, }: { store: StoreApi>; tab: TabType; @@ -48,10 +50,18 @@ function SplitView({ registry: PaneRegistry; paneActions?: TabProps["paneActions"]; contextMenuActions?: TabProps["contextMenuActions"]; + onSplitResizeDragging?: TabProps["onSplitResizeDragging"]; }) { const groupRef = useRef>(null); const firstSize = node.splitPercentage ?? 50; const secondSize = 100 - firstSize; + const resizeSourceId = `${tab.id}:${path.join(".") || "root"}`; + + useEffect(() => { + return () => { + onSplitResizeDragging?.(resizeSourceId, false); + }; + }, [onSplitResizeDragging, resizeSourceId]); return ( ({ registry={registry} paneActions={paneActions} contextMenuActions={contextMenuActions} + onSplitResizeDragging={onSplitResizeDragging} parentDirection={node.direction} /> - + + onSplitResizeDragging?.(resizeSourceId, isDragging) + } + /> ({ registry={registry} paneActions={paneActions} contextMenuActions={contextMenuActions} + onSplitResizeDragging={onSplitResizeDragging} parentDirection={node.direction} /> @@ -115,6 +131,7 @@ function LayoutNodeView({ registry, paneActions, contextMenuActions, + onSplitResizeDragging, parentDirection = null, }: { store: StoreApi>; @@ -124,6 +141,7 @@ function LayoutNodeView({ registry: PaneRegistry; paneActions?: TabProps["paneActions"]; contextMenuActions?: TabProps["contextMenuActions"]; + onSplitResizeDragging?: TabProps["onSplitResizeDragging"]; parentDirection?: "horizontal" | "vertical" | null; }) { if (node.type === "pane") { @@ -153,6 +171,7 @@ function LayoutNodeView({ registry={registry} paneActions={paneActions} contextMenuActions={contextMenuActions} + onSplitResizeDragging={onSplitResizeDragging} /> ); } @@ -163,6 +182,7 @@ export function Tab({ registry, paneActions, contextMenuActions, + onSplitResizeDragging, }: TabProps) { if (!tab.layout) { return ( @@ -182,6 +202,7 @@ export function Tab({ registry={registry} paneActions={paneActions} contextMenuActions={contextMenuActions} + onSplitResizeDragging={onSplitResizeDragging} />
); diff --git a/packages/panes/src/react/components/Workspace/hooks/useWorkspaceInteractionState/index.ts b/packages/panes/src/react/components/Workspace/hooks/useWorkspaceInteractionState/index.ts new file mode 100644 index 00000000000..0fb538d4c1f --- /dev/null +++ b/packages/panes/src/react/components/Workspace/hooks/useWorkspaceInteractionState/index.ts @@ -0,0 +1 @@ +export { useWorkspaceInteractionState } from "./useWorkspaceInteractionState"; diff --git a/packages/panes/src/react/components/Workspace/hooks/useWorkspaceInteractionState/useWorkspaceInteractionState.ts b/packages/panes/src/react/components/Workspace/hooks/useWorkspaceInteractionState/useWorkspaceInteractionState.ts new file mode 100644 index 00000000000..2693732f6f6 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/hooks/useWorkspaceInteractionState/useWorkspaceInteractionState.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { WorkspaceInteractionState } from "../../../../types"; + +interface UseWorkspaceInteractionStateOptions { + onInteractionStateChange?: (state: WorkspaceInteractionState) => void; +} + +export function useWorkspaceInteractionState({ + onInteractionStateChange, +}: UseWorkspaceInteractionStateOptions) { + const splitResizeSourcesRef = useRef>(new Set()); + const resizeActiveRef = useRef(false); + const onInteractionStateChangeRef = useRef(onInteractionStateChange); + onInteractionStateChangeRef.current = onInteractionStateChange; + + const emitResizeActive = useCallback((resizeActive: boolean) => { + if (resizeActiveRef.current === resizeActive) return; + resizeActiveRef.current = resizeActive; + onInteractionStateChangeRef.current?.({ resizeActive }); + }, []); + + const clearResizeSources = useCallback(() => { + splitResizeSourcesRef.current.clear(); + emitResizeActive(false); + }, [emitResizeActive]); + + const onSplitResizeDragging = useCallback( + (sourceId: string, isDragging: boolean) => { + if (isDragging) { + splitResizeSourcesRef.current.add(sourceId); + } else { + splitResizeSourcesRef.current.delete(sourceId); + } + emitResizeActive(splitResizeSourcesRef.current.size > 0); + }, + [emitResizeActive], + ); + + useEffect(() => { + window.addEventListener("blur", clearResizeSources); + return () => { + window.removeEventListener("blur", clearResizeSources); + clearResizeSources(); + }; + }, [clearResizeSources]); + + return { onSplitResizeDragging }; +} diff --git a/packages/panes/src/react/index.ts b/packages/panes/src/react/index.ts index 4d825812559..a6fdb1547b4 100644 --- a/packages/panes/src/react/index.ts +++ b/packages/panes/src/react/index.ts @@ -7,5 +7,6 @@ export type { PaneRegistry, RendererContext, TabContext, + WorkspaceInteractionState, WorkspaceProps, } from "./types"; diff --git a/packages/panes/src/react/types.ts b/packages/panes/src/react/types.ts index ab4b3058de0..f91ccd0fc0c 100644 --- a/packages/panes/src/react/types.ts +++ b/packages/panes/src/react/types.ts @@ -82,6 +82,10 @@ export interface PaneDefinition { export type PaneRegistry = Record>; +export interface WorkspaceInteractionState { + resizeActive: boolean; +} + export interface WorkspaceProps { store: StoreApi>; registry: PaneRegistry; @@ -96,6 +100,7 @@ export interface WorkspaceProps { tab: Tab, ) => boolean | Promise; onBeforeCloseTab?: (tab: Tab) => boolean | Promise; + onInteractionStateChange?: (state: WorkspaceInteractionState) => void; paneActions?: | PaneActionConfig[] | ((context: RendererContext) => PaneActionConfig[]);