From 4e0a27a77717ab4e8fe444ef0c25f6ea1f3c9560 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 16 Apr 2026 12:53:44 -0700 Subject: [PATCH 01/12] feat(desktop): v2 terminal honors terminalLinkBehavior setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user's "Terminal file links" setting is "file-viewer", Cmd/Ctrl-clicking a file path in a v2 workspace terminal now opens the file in an in-app FilePane instead of the external editor — matching the v1 behavior the setting already controls. Directories and the "external-editor" setting continue to fall through to the existing openFileInEditor path. --- .../components/TerminalPane/TerminalPane.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 4eb5af43698..bf6160a4455 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -16,6 +16,7 @@ import { } from "renderer/lib/terminal/terminal-runtime-registry"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import type { + FilePaneData, PaneViewerData, TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; @@ -124,6 +125,9 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { const statPathRef = useRef(statPathMutation.mutateAsync); statPathRef.current = statPathMutation.mutateAsync; + const paneStoreRef = useRef(ctx.store); + paneStoreRef.current = ctx.store; + useEffect(() => { terminalRuntimeRegistry.setLinkHandlers(terminalId, { stat: async (path) => { @@ -141,9 +145,26 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { return null; } }, - onFileLinkClick: (_event, link) => { - if (!_event.metaKey && !_event.ctrlKey) return; - _event.preventDefault(); + onFileLinkClick: async (event, link) => { + if (!event.metaKey && !event.ctrlKey) return; + event.preventDefault(); + const behavior = + await electronTrpcClient.settings.getTerminalLinkBehavior + .query() + .catch(() => "file-viewer" as const); + if (behavior === "file-viewer" && !link.isDirectory) { + paneStoreRef.current.getState().openPane({ + pane: { + kind: "file", + data: { + filePath: link.resolvedPath, + mode: "editor", + hasChanges: false, + } satisfies FilePaneData, + }, + }); + return; + } electronTrpcClient.external.openFileInEditor .mutate({ path: link.resolvedPath, From 45fdab54d8997d9a1d43fd71825abd2a374d421d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 10:06:00 -0700 Subject: [PATCH 02/12] feat(desktop): modifier-keyed v2 terminal file links + folder sidebar reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the settings-based branch with a modifier-key pattern: - Cmd/Ctrl-click a file path → opens in an in-app FilePane. - Cmd/Ctrl+Shift-click a file or directory → opens in the external editor (with an upfront toast for remote workspaces, same pattern as FilesTab's Open in editor guard). - Cmd/Ctrl-click a directory path → force-opens the sidebar, reveals the folder in the file tree (ancestors expand, row scrolls into view and highlights). Implementation reuses the existing selectedFilePath → fileTree.reveal machinery in FilesTab by promoting selectedFilePath from a pane-store derivation to a useState, synced from the active file pane via useEffect. Folder focus is just a direct setSelectedFilePath — the existing sidebar code path handles reveal + scroll + highlight without changes. Folder paths now also flow through getParentForCreation, so the "New File" toolbar button creates inside the focused folder. Three callbacks (onOpenFile / onRevealPath / onOpenExternal) are plumbed from page.tsx through usePaneRegistry to TerminalPane. The shift-modifier path goes through openExternal, which checks workspaceHost.hostMachineId !== machineId and toasts for remote workspaces instead of firing a mutation the remote won't satisfy. v1 code untouched; DB schema untouched; v2 settings UI untouched (terminalLinkBehavior still honored by v1). --- .../components/TerminalPane/TerminalPane.tsx | 59 ++++++++------- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 27 ++++++- .../v2-workspace/$workspaceId/page.tsx | 72 +++++++++++++++++-- 3 files changed, 122 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index bf6160a4455..e169984d122 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -1,5 +1,4 @@ import type { RendererContext } from "@superset/panes"; -import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import "@xterm/xterm/css/xterm.css"; import { @@ -16,7 +15,6 @@ import { } from "renderer/lib/terminal/terminal-runtime-registry"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import type { - FilePaneData, PaneViewerData, TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; @@ -30,6 +28,12 @@ import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; interface TerminalPaneProps { ctx: RendererContext; workspaceId: string; + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string) => void; + onOpenExternal: ( + path: string, + opts?: { line?: number; column?: number }, + ) => void; } function subscribeToState(terminalId: string) { @@ -41,7 +45,13 @@ function getConnectionState(terminalId: string): ConnectionState { return terminalRuntimeRegistry.getConnectionState(terminalId); } -export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { +export function TerminalPane({ + ctx, + workspaceId, + onOpenFile, + onRevealPath, + onOpenExternal, +}: TerminalPaneProps) { const paneData = ctx.pane.data as TerminalPaneData; const { terminalId } = paneData; const containerRef = useRef(null); @@ -125,8 +135,12 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { const statPathRef = useRef(statPathMutation.mutateAsync); statPathRef.current = statPathMutation.mutateAsync; - const paneStoreRef = useRef(ctx.store); - paneStoreRef.current = ctx.store; + const onOpenFileRef = useRef(onOpenFile); + onOpenFileRef.current = onOpenFile; + const onRevealPathRef = useRef(onRevealPath); + onRevealPathRef.current = onRevealPath; + const onOpenExternalRef = useRef(onOpenExternal); + onOpenExternalRef.current = onOpenExternal; useEffect(() => { terminalRuntimeRegistry.setLinkHandlers(terminalId, { @@ -145,36 +159,21 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { return null; } }, - onFileLinkClick: async (event, link) => { + onFileLinkClick: (event, link) => { if (!event.metaKey && !event.ctrlKey) return; event.preventDefault(); - const behavior = - await electronTrpcClient.settings.getTerminalLinkBehavior - .query() - .catch(() => "file-viewer" as const); - if (behavior === "file-viewer" && !link.isDirectory) { - paneStoreRef.current.getState().openPane({ - pane: { - kind: "file", - data: { - filePath: link.resolvedPath, - mode: "editor", - hasChanges: false, - } satisfies FilePaneData, - }, - }); - return; - } - electronTrpcClient.external.openFileInEditor - .mutate({ - path: link.resolvedPath, + if (event.shiftKey) { + onOpenExternalRef.current(link.resolvedPath, { line: link.row, column: link.col, - }) - .catch((error) => { - console.error("[v2 Terminal] Failed to open file:", error); - toast.error("Failed to open file in editor"); }); + return; + } + if (link.isDirectory) { + onRevealPathRef.current(link.resolvedPath); + } else { + onOpenFileRef.current(link.resolvedPath); + } }, onUrlClick: (url) => { electronTrpcClient.external.openUrl.mutate(url).catch((error) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index e394fd3f5d6..82c61dd4e9a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -140,8 +140,18 @@ function DiffViewModeToggle() { ); } +interface UsePaneRegistryOptions { + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string) => void; + onOpenExternal: ( + path: string, + opts?: { line?: number; column?: number }, + ) => void; +} + export function usePaneRegistry( workspaceId: string, + { onOpenFile, onRevealPath, onOpenExternal }: UsePaneRegistryOptions, ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; @@ -237,7 +247,13 @@ export function usePaneRegistry( getIcon: () => , getTitle: () => "Terminal", renderPane: (ctx: RendererContext) => ( - + ), contextMenuActions: (_ctx, defaults) => { const terminalActions: ContextMenuActionConfig[] = [ @@ -411,6 +427,13 @@ export function usePaneRegistry( }, }, }), - [workspaceId, clearShortcut, scrollToBottomShortcut], + [ + workspaceId, + clearShortcut, + scrollToBottomShortcut, + onOpenFile, + onRevealPath, + onOpenExternal, + ], ); } 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 98d7daa704f..8d1568568ec 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 @@ -5,15 +5,18 @@ import { ResizablePanel, ResizablePanelGroup, } from "@superset/ui/resizable"; +import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute } from "@tanstack/react-router"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import { HotkeyLabel, useHotkey } from "renderer/hotkeys"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; import { toAbsoluteWorkspacePath, @@ -92,6 +95,7 @@ function WorkspaceContent({ workspaceId: string; workspaceName: string; }) { + const collections = useCollections(); const { localWorkspaceState, store } = useV2WorkspacePaneLayout({ projectId, workspaceId, @@ -102,8 +106,6 @@ function WorkspaceContent({ projectId, }); useConsumePendingLaunch({ workspaceId, store }); - const paneRegistry = usePaneRegistry(workspaceId); - const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ id: workspaceId, @@ -112,7 +114,23 @@ function WorkspaceContent({ const { recentFiles, recordView } = useRecentlyViewedFiles(workspaceId); - const selectedFilePath = useStore(store, (s) => { + const { machineId } = useLocalHostService(); + const { data: workspacesWithHost = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .leftJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.id), + ) + .where(({ workspaces }) => eq(workspaces.id, workspaceId)) + .select(({ hosts }) => ({ + hostMachineId: hosts?.machineId ?? null, + })), + [collections, workspaceId], + ); + const workspaceHost = workspacesWithHost[0]; + + const activeFilePanePath = useStore(store, (s) => { const tab = s.tabs.find((t) => t.id === s.activeTabId); if (!tab?.activePaneId) return undefined; const pane = tab.panes[tab.activePaneId]; @@ -120,6 +138,16 @@ function WorkspaceContent({ return undefined; }); + const [selectedFilePath, setSelectedFilePath] = useState( + activeFilePanePath, + ); + + useEffect(() => { + if (activeFilePanePath !== undefined) { + setSelectedFilePath(activeFilePanePath); + } + }, [activeFilePanePath]); + const openFilePathsKey = useStore(store, (s) => s.tabs .flatMap((t) => @@ -179,6 +207,42 @@ function WorkspaceContent({ [store, worktreePath, recordView], ); + const revealPath = useCallback( + (path: string) => { + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.rightSidebarOpen = true; + }); + setSelectedFilePath(path); + }, + [collections, workspaceId], + ); + + const openExternal = useCallback( + (path: string, opts?: { line?: number; column?: number }) => { + if (workspaceHost && workspaceHost.hostMachineId !== machineId) { + toast.error("Can't open remote workspace paths in an external editor"); + return; + } + electronTrpcClient.external.openFileInEditor + .mutate({ path, line: opts?.line, column: opts?.column }) + .catch((error) => { + console.error( + "[v2 Terminal] Failed to open in external editor:", + error, + ); + toast.error("Failed to open in external editor"); + }); + }, + [workspaceHost, machineId], + ); + + const paneRegistry = usePaneRegistry(workspaceId, { + onOpenFile: openFilePane, + onRevealPath: revealPath, + onOpenExternal: openExternal, + }); + const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); + const openDiffPane = useCallback( (filePath: string) => { const state = store.getState(); From 6369a153f614004472e636d326afa48c37e96b31 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 10:12:43 -0700 Subject: [PATCH 03/12] refactor(desktop): drop callback refs in v2 TerminalPane, use effect deps directly --- .../components/TerminalPane/TerminalPane.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index e169984d122..17220f107e8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -135,13 +135,6 @@ export function TerminalPane({ const statPathRef = useRef(statPathMutation.mutateAsync); statPathRef.current = statPathMutation.mutateAsync; - const onOpenFileRef = useRef(onOpenFile); - onOpenFileRef.current = onOpenFile; - const onRevealPathRef = useRef(onRevealPath); - onRevealPathRef.current = onRevealPath; - const onOpenExternalRef = useRef(onOpenExternal); - onOpenExternalRef.current = onOpenExternal; - useEffect(() => { terminalRuntimeRegistry.setLinkHandlers(terminalId, { stat: async (path) => { @@ -163,16 +156,16 @@ export function TerminalPane({ if (!event.metaKey && !event.ctrlKey) return; event.preventDefault(); if (event.shiftKey) { - onOpenExternalRef.current(link.resolvedPath, { + onOpenExternal(link.resolvedPath, { line: link.row, column: link.col, }); return; } if (link.isDirectory) { - onRevealPathRef.current(link.resolvedPath); + onRevealPath(link.resolvedPath); } else { - onOpenFileRef.current(link.resolvedPath); + onOpenFile(link.resolvedPath); } }, onUrlClick: (url) => { @@ -181,7 +174,7 @@ export function TerminalPane({ }); }, }); - }, [terminalId, workspaceId]); + }, [terminalId, workspaceId, onOpenFile, onRevealPath, onOpenExternal]); useHotkey( "CLEAR_TERMINAL", From 12e8e2f411608a83abeeeaa620be5a540eda2045 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 10:23:44 -0700 Subject: [PATCH 04/12] refactor(desktop): move v2 pane actions into a PaneActionsProvider context Removes the terminal-specific callback plumbing from usePaneRegistry (which should only care about how to render each pane kind) and moves onOpenFile / onRevealPath / onOpenExternal into a React context scoped to the workspace page. TerminalPane consumes via usePaneActions() instead of taking them as props. --- .../components/TerminalPane/TerminalPane.tsx | 16 +- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 27 +- .../v2-workspace/$workspaceId/page.tsx | 247 +++++++++--------- .../PaneActionsProvider.tsx | 34 +++ .../providers/PaneActionsProvider/index.ts | 5 + 5 files changed, 170 insertions(+), 159 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/PaneActionsProvider.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 17220f107e8..8e31a33b1b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -14,6 +14,7 @@ import { terminalRuntimeRegistry, } from "renderer/lib/terminal/terminal-runtime-registry"; import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { usePaneActions } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider"; import type { PaneViewerData, TerminalPaneData, @@ -28,12 +29,6 @@ import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; interface TerminalPaneProps { ctx: RendererContext; workspaceId: string; - onOpenFile: (path: string, openInNewTab?: boolean) => void; - onRevealPath: (path: string) => void; - onOpenExternal: ( - path: string, - opts?: { line?: number; column?: number }, - ) => void; } function subscribeToState(terminalId: string) { @@ -45,13 +40,8 @@ function getConnectionState(terminalId: string): ConnectionState { return terminalRuntimeRegistry.getConnectionState(terminalId); } -export function TerminalPane({ - ctx, - workspaceId, - onOpenFile, - onRevealPath, - onOpenExternal, -}: TerminalPaneProps) { +export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { + const { onOpenFile, onRevealPath, onOpenExternal } = usePaneActions(); const paneData = ctx.pane.data as TerminalPaneData; const { terminalId } = paneData; const containerRef = useRef(null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 82c61dd4e9a..e394fd3f5d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -140,18 +140,8 @@ function DiffViewModeToggle() { ); } -interface UsePaneRegistryOptions { - onOpenFile: (path: string, openInNewTab?: boolean) => void; - onRevealPath: (path: string) => void; - onOpenExternal: ( - path: string, - opts?: { line?: number; column?: number }, - ) => void; -} - export function usePaneRegistry( workspaceId: string, - { onOpenFile, onRevealPath, onOpenExternal }: UsePaneRegistryOptions, ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; @@ -247,13 +237,7 @@ export function usePaneRegistry( getIcon: () => , getTitle: () => "Terminal", renderPane: (ctx: RendererContext) => ( - + ), contextMenuActions: (_ctx, defaults) => { const terminalActions: ContextMenuActionConfig[] = [ @@ -427,13 +411,6 @@ export function usePaneRegistry( }, }, }), - [ - workspaceId, - clearShortcut, - scrollToBottomShortcut, - onOpenFile, - onRevealPath, - onOpenExternal, - ], + [workspaceId, clearShortcut, scrollToBottomShortcut], ); } 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 8d1568568ec..a301a170592 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 @@ -36,6 +36,7 @@ import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; +import { PaneActionsProvider } from "./providers/PaneActionsProvider"; import { FileDocumentStoreProvider, getDocument, @@ -236,11 +237,7 @@ function WorkspaceContent({ [workspaceHost, machineId], ); - const paneRegistry = usePaneRegistry(workspaceId, { - onOpenFile: openFilePane, - onRevealPath: revealPath, - onOpenExternal: openExternal, - }); + const paneRegistry = usePaneRegistry(workspaceId); const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); const openDiffPane = useCallback( @@ -389,127 +386,135 @@ function WorkspaceContent({ return ( - - -
- - registry={paneRegistry} - paneActions={defaultPaneActions} - contextMenuActions={defaultContextMenuActions} - renderTabIcon={renderBrowserTabIcon} - renderBelowTabBar={() => ( - - )} - renderAddTabMenu={() => ( - - )} - renderEmptyState={() => ( - - )} - 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) => - (p.data as FilePaneData).filePath.split("/").pop(), - ); - 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; + + + +
+ + registry={paneRegistry} + paneActions={defaultPaneActions} + contextMenuActions={defaultContextMenuActions} + renderTabIcon={renderBrowserTabIcon} + renderBelowTabBar={() => ( + + )} + renderAddTabMenu={() => ( + + )} + renderEmptyState={() => ( + + )} + 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) => + (p.data as FilePaneData).filePath.split("/").pop(), + ); + 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); + 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), }, - }, - { - label: "Cancel", - variant: "ghost", - onClick: () => resolve(false), - }, - ], + ], + }); }); - }); - }} - store={store} - /> -
-
- {sidebarOpen && ( - <> - - - - - - )} -
- +
+
+ {sidebarOpen && ( + <> + + + + + + )} +
+ +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/PaneActionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/PaneActionsProvider.tsx new file mode 100644 index 00000000000..15047e3f4eb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/PaneActionsProvider.tsx @@ -0,0 +1,34 @@ +import { createContext, type ReactNode, useContext } from "react"; + +export interface PaneActions { + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string) => void; + onOpenExternal: ( + path: string, + opts?: { line?: number; column?: number }, + ) => void; +} + +const PaneActionsContext = createContext(null); + +export function PaneActionsProvider({ + value, + children, +}: { + value: PaneActions; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function usePaneActions(): PaneActions { + const value = useContext(PaneActionsContext); + if (!value) { + throw new Error("usePaneActions must be used inside PaneActionsProvider"); + } + return value; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/index.ts new file mode 100644 index 00000000000..a1e78d2497b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/index.ts @@ -0,0 +1,5 @@ +export { + type PaneActions, + PaneActionsProvider, + usePaneActions, +} from "./PaneActionsProvider"; From e934fbfa61249e516263eef34ac396b7ab69f594 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 12:03:52 -0700 Subject: [PATCH 05/12] refactor(desktop): drop PaneActionsProvider, pass actions through usePaneRegistry The context indirection wasn't worth it for a single consumer (TerminalPane). Passing the three callbacks through usePaneRegistry options is simpler and has no actual downside since usePaneRegistry is only called from one place anyway. --- .../components/TerminalPane/TerminalPane.tsx | 16 +- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 27 +- .../v2-workspace/$workspaceId/page.tsx | 247 +++++++++--------- .../PaneActionsProvider.tsx | 34 --- .../providers/PaneActionsProvider/index.ts | 5 - 5 files changed, 159 insertions(+), 170 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/PaneActionsProvider.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 8e31a33b1b5..17220f107e8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -14,7 +14,6 @@ import { terminalRuntimeRegistry, } from "renderer/lib/terminal/terminal-runtime-registry"; import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { usePaneActions } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider"; import type { PaneViewerData, TerminalPaneData, @@ -29,6 +28,12 @@ import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; interface TerminalPaneProps { ctx: RendererContext; workspaceId: string; + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string) => void; + onOpenExternal: ( + path: string, + opts?: { line?: number; column?: number }, + ) => void; } function subscribeToState(terminalId: string) { @@ -40,8 +45,13 @@ function getConnectionState(terminalId: string): ConnectionState { return terminalRuntimeRegistry.getConnectionState(terminalId); } -export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { - const { onOpenFile, onRevealPath, onOpenExternal } = usePaneActions(); +export function TerminalPane({ + ctx, + workspaceId, + onOpenFile, + onRevealPath, + onOpenExternal, +}: TerminalPaneProps) { const paneData = ctx.pane.data as TerminalPaneData; const { terminalId } = paneData; const containerRef = useRef(null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index e394fd3f5d6..82c61dd4e9a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -140,8 +140,18 @@ function DiffViewModeToggle() { ); } +interface UsePaneRegistryOptions { + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string) => void; + onOpenExternal: ( + path: string, + opts?: { line?: number; column?: number }, + ) => void; +} + export function usePaneRegistry( workspaceId: string, + { onOpenFile, onRevealPath, onOpenExternal }: UsePaneRegistryOptions, ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; @@ -237,7 +247,13 @@ export function usePaneRegistry( getIcon: () => , getTitle: () => "Terminal", renderPane: (ctx: RendererContext) => ( - + ), contextMenuActions: (_ctx, defaults) => { const terminalActions: ContextMenuActionConfig[] = [ @@ -411,6 +427,13 @@ export function usePaneRegistry( }, }, }), - [workspaceId, clearShortcut, scrollToBottomShortcut], + [ + workspaceId, + clearShortcut, + scrollToBottomShortcut, + onOpenFile, + onRevealPath, + onOpenExternal, + ], ); } 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 a301a170592..8d1568568ec 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 @@ -36,7 +36,6 @@ import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; -import { PaneActionsProvider } from "./providers/PaneActionsProvider"; import { FileDocumentStoreProvider, getDocument, @@ -237,7 +236,11 @@ function WorkspaceContent({ [workspaceHost, machineId], ); - const paneRegistry = usePaneRegistry(workspaceId); + const paneRegistry = usePaneRegistry(workspaceId, { + onOpenFile: openFilePane, + onRevealPath: revealPath, + onOpenExternal: openExternal, + }); const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); const openDiffPane = useCallback( @@ -386,135 +389,127 @@ function WorkspaceContent({ return ( - - - -
- - registry={paneRegistry} - paneActions={defaultPaneActions} - contextMenuActions={defaultContextMenuActions} - renderTabIcon={renderBrowserTabIcon} - renderBelowTabBar={() => ( - - )} - renderAddTabMenu={() => ( - - )} - renderEmptyState={() => ( - - )} - 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) => - (p.data as FilePaneData).filePath.split("/").pop(), - ); - 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(); + + +
+ + registry={paneRegistry} + paneActions={defaultPaneActions} + contextMenuActions={defaultContextMenuActions} + renderTabIcon={renderBrowserTabIcon} + renderBelowTabBar={() => ( + + )} + renderAddTabMenu={() => ( + + )} + renderEmptyState={() => ( + + )} + 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) => + (p.data as FilePaneData).filePath.split("/").pop(), + ); + 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); - }, + } + resolve(true); }, - { - label: "Cancel", - variant: "ghost", - onClick: () => resolve(false), + }, + { + 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), + }, + ], }); - }} - store={store} + }); + }} + store={store} + /> +
+
+ {sidebarOpen && ( + <> + + + -
-
- {sidebarOpen && ( - <> - - - - - - )} -
- -
+ + + )} + +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/PaneActionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/PaneActionsProvider.tsx deleted file mode 100644 index 15047e3f4eb..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/PaneActionsProvider.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, type ReactNode, useContext } from "react"; - -export interface PaneActions { - onOpenFile: (path: string, openInNewTab?: boolean) => void; - onRevealPath: (path: string) => void; - onOpenExternal: ( - path: string, - opts?: { line?: number; column?: number }, - ) => void; -} - -const PaneActionsContext = createContext(null); - -export function PaneActionsProvider({ - value, - children, -}: { - value: PaneActions; - children: ReactNode; -}) { - return ( - - {children} - - ); -} - -export function usePaneActions(): PaneActions { - const value = useContext(PaneActionsContext); - if (!value) { - throw new Error("usePaneActions must be used inside PaneActionsProvider"); - } - return value; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/index.ts deleted file mode 100644 index a1e78d2497b..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/PaneActionsProvider/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - type PaneActions, - PaneActionsProvider, - usePaneActions, -} from "./PaneActionsProvider"; From 05fe825b84fcdd487ed6f8e6f4162d30fa3434d4 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 12:09:01 -0700 Subject: [PATCH 06/12] fix(desktop): v2 terminal folder reveal switches sidebar to Files tab Previously revealing a directory only toggled rightSidebarOpen + setSelectedFilePath, but the sidebar would stay on whichever tab the user had last (e.g., Changes/Review), leaving FilesTab unmounted so the reveal effect never fired. Update the same v2WorkspaceLocalState transaction to also force sidebarState.activeTab back to "files". --- .../_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx | 1 + 1 file changed, 1 insertion(+) 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 8d1568568ec..f9705bf16df 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 @@ -211,6 +211,7 @@ function WorkspaceContent({ (path: string) => { collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { draft.rightSidebarOpen = true; + draft.sidebarState.activeTab = "files"; }); setSelectedFilePath(path); }, From 053564de2a370034114466e3d240eec10c33bab3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 12:16:14 -0700 Subject: [PATCH 07/12] fix(desktop): auto-expand revealed directory + extract useOpenInExternalEditor - useFileTree.reveal now also expands the target itself when it's a directory, using stateRef so there's no staleness concern. All reveal call sites benefit. - Extract useOpenInExternalEditor hook (remote check + toast + mutate) so TerminalPane can consume it directly instead of through a callback. Drops one prop from usePaneRegistry and removes the local workspaceHost liveQuery from page.tsx. FilesTab's handleOpenInEditor could migrate to the same hook in a follow-up to dedupe the pattern. --- .../host-service/useFileTree/useFileTree.ts | 7 ++- .../hooks/useOpenInExternalEditor/index.ts | 4 ++ .../useOpenInExternalEditor.ts | 47 +++++++++++++++++++ .../components/TerminalPane/TerminalPane.tsx | 11 ++--- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 8 +--- .../v2-workspace/$workspaceId/page.tsx | 39 --------------- 6 files changed, 61 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts diff --git a/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts b/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts index 375c756db1a..54c47626d68 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts @@ -485,7 +485,6 @@ export function useFileTree({ async (absolutePath: string): Promise => { if (!rootPath || !absolutePath.startsWith(rootPath)) return; - // Collect ancestor directories from rootPath down to the parent of the target const ancestors: string[] = []; let current = getParentPath(absolutePath); while (current.length >= rootPath.length && current !== absolutePath) { @@ -494,10 +493,14 @@ export function useFileTree({ current = getParentPath(current); } - // Expand all ancestors and load their contents for (const dir of ancestors) { await expand(dir); } + + const entry = stateRef.current.entriesByPath.get(absolutePath); + if (entry?.kind === "directory") { + await expand(absolutePath); + } }, [expand, rootPath], ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/index.ts new file mode 100644 index 00000000000..de169c7f90c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/index.ts @@ -0,0 +1,4 @@ +export { + type OpenInExternalEditorOptions, + useOpenInExternalEditor, +} from "./useOpenInExternalEditor"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts new file mode 100644 index 00000000000..6a55d915fe9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts @@ -0,0 +1,47 @@ +import { toast } from "@superset/ui/sonner"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +export interface OpenInExternalEditorOptions { + line?: number; + column?: number; +} + +export function useOpenInExternalEditor(workspaceId: string) { + const collections = useCollections(); + const { machineId } = useLocalHostService(); + const { data: workspacesWithHost = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .leftJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.id), + ) + .where(({ workspaces }) => eq(workspaces.id, workspaceId)) + .select(({ hosts }) => ({ + hostMachineId: hosts?.machineId ?? null, + })), + [collections, workspaceId], + ); + const workspaceHost = workspacesWithHost[0]; + + return useCallback( + (path: string, opts?: OpenInExternalEditorOptions) => { + if (workspaceHost && workspaceHost.hostMachineId !== machineId) { + toast.error("Can't open remote workspace paths in an external editor"); + return; + } + electronTrpcClient.external.openFileInEditor + .mutate({ path, line: opts?.line, column: opts?.column }) + .catch((error) => { + console.error("Failed to open in external editor:", error); + toast.error("Failed to open in external editor"); + }); + }, + [workspaceHost, machineId], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 17220f107e8..11848e3ebd0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -14,6 +14,7 @@ import { terminalRuntimeRegistry, } from "renderer/lib/terminal/terminal-runtime-registry"; import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; import type { PaneViewerData, TerminalPaneData, @@ -30,10 +31,6 @@ interface TerminalPaneProps { workspaceId: string; onOpenFile: (path: string, openInNewTab?: boolean) => void; onRevealPath: (path: string) => void; - onOpenExternal: ( - path: string, - opts?: { line?: number; column?: number }, - ) => void; } function subscribeToState(terminalId: string) { @@ -50,8 +47,8 @@ export function TerminalPane({ workspaceId, onOpenFile, onRevealPath, - onOpenExternal, }: TerminalPaneProps) { + const openInExternalEditor = useOpenInExternalEditor(workspaceId); const paneData = ctx.pane.data as TerminalPaneData; const { terminalId } = paneData; const containerRef = useRef(null); @@ -156,7 +153,7 @@ export function TerminalPane({ if (!event.metaKey && !event.ctrlKey) return; event.preventDefault(); if (event.shiftKey) { - onOpenExternal(link.resolvedPath, { + openInExternalEditor(link.resolvedPath, { line: link.row, column: link.col, }); @@ -174,7 +171,7 @@ export function TerminalPane({ }); }, }); - }, [terminalId, workspaceId, onOpenFile, onRevealPath, onOpenExternal]); + }, [terminalId, workspaceId, onOpenFile, onRevealPath, openInExternalEditor]); useHotkey( "CLEAR_TERMINAL", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 82c61dd4e9a..331ff997007 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -143,15 +143,11 @@ function DiffViewModeToggle() { interface UsePaneRegistryOptions { onOpenFile: (path: string, openInNewTab?: boolean) => void; onRevealPath: (path: string) => void; - onOpenExternal: ( - path: string, - opts?: { line?: number; column?: number }, - ) => void; } export function usePaneRegistry( workspaceId: string, - { onOpenFile, onRevealPath, onOpenExternal }: UsePaneRegistryOptions, + { onOpenFile, onRevealPath }: UsePaneRegistryOptions, ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; @@ -252,7 +248,6 @@ export function usePaneRegistry( workspaceId={workspaceId} onOpenFile={onOpenFile} onRevealPath={onRevealPath} - onOpenExternal={onOpenExternal} /> ), contextMenuActions: (_ctx, defaults) => { @@ -433,7 +428,6 @@ export function usePaneRegistry( scrollToBottomShortcut, onOpenFile, onRevealPath, - onOpenExternal, ], ); } 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 f9705bf16df..e3d1feb74e4 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 @@ -5,7 +5,6 @@ import { ResizablePanel, ResizablePanelGroup, } from "@superset/ui/resizable"; -import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; @@ -14,9 +13,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import { HotkeyLabel, useHotkey } from "renderer/hotkeys"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; import { toAbsoluteWorkspacePath, @@ -114,22 +111,6 @@ function WorkspaceContent({ const { recentFiles, recordView } = useRecentlyViewedFiles(workspaceId); - const { machineId } = useLocalHostService(); - const { data: workspacesWithHost = [] } = useLiveQuery( - (q) => - q - .from({ workspaces: collections.v2Workspaces }) - .leftJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => - eq(workspaces.hostId, hosts.id), - ) - .where(({ workspaces }) => eq(workspaces.id, workspaceId)) - .select(({ hosts }) => ({ - hostMachineId: hosts?.machineId ?? null, - })), - [collections, workspaceId], - ); - const workspaceHost = workspacesWithHost[0]; - const activeFilePanePath = useStore(store, (s) => { const tab = s.tabs.find((t) => t.id === s.activeTabId); if (!tab?.activePaneId) return undefined; @@ -218,29 +199,9 @@ function WorkspaceContent({ [collections, workspaceId], ); - const openExternal = useCallback( - (path: string, opts?: { line?: number; column?: number }) => { - if (workspaceHost && workspaceHost.hostMachineId !== machineId) { - toast.error("Can't open remote workspace paths in an external editor"); - return; - } - electronTrpcClient.external.openFileInEditor - .mutate({ path, line: opts?.line, column: opts?.column }) - .catch((error) => { - console.error( - "[v2 Terminal] Failed to open in external editor:", - error, - ); - toast.error("Failed to open in external editor"); - }); - }, - [workspaceHost, machineId], - ); - const paneRegistry = usePaneRegistry(workspaceId, { onOpenFile: openFilePane, onRevealPath: revealPath, - onOpenExternal: openExternal, }); const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); From 9e87499db57fdc4222d32108c9135ae387675777 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 12:24:47 -0700 Subject: [PATCH 08/12] feat(desktop): v2 terminal URL links open in internal browser by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd/Ctrl-click a URL in a v2 terminal now opens a BrowserPane in the current tab. Cmd/Ctrl+Shift-click still opens in the external browser. Widens TerminalLinkHandlers.onUrlClick to receive the MouseEvent (v1's helper just threads it through unused — behavior unchanged). --- .../lib/terminal/terminal-link-manager.ts | 6 ++--- .../components/TerminalPane/TerminalPane.tsx | 25 ++++++++++++++++--- .../TabsContent/Terminal/helpers.ts | 2 +- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts index c045142cc0a..cdce4f79d10 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts @@ -25,7 +25,7 @@ export interface TerminalLinkHandlers { /** Called when a file path link is activated (Cmd/Ctrl+click). */ onFileLinkClick?: (event: MouseEvent, link: DetectedLink) => void; /** Called when a URL link is activated. */ - onUrlClick?: (url: string) => void; + onUrlClick?: (event: MouseEvent, url: string) => void; /** * Stat callback to validate file paths exist. Called via the host service * which handles all path resolution (relative, tilde, etc.) server-side. @@ -105,8 +105,8 @@ export class TerminalLinkManager { // 2. URL link provider (handles hard-wrapped URLs) if (handlers.onUrlClick) { const onUrlClick = handlers.onUrlClick; - const urlProvider = new UrlLinkProvider(this._terminal, (_event, uri) => { - onUrlClick(uri); + const urlProvider = new UrlLinkProvider(this._terminal, (event, uri) => { + onUrlClick(event, uri); }); this._disposables.push(this._terminal.registerLinkProvider(urlProvider)); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 11848e3ebd0..c2a40a27ace 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -16,6 +16,7 @@ import { import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; import type { + BrowserPaneData, PaneViewerData, TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; @@ -165,13 +166,29 @@ export function TerminalPane({ onOpenFile(link.resolvedPath); } }, - onUrlClick: (url) => { - electronTrpcClient.external.openUrl.mutate(url).catch((error) => { - console.error("[v2 Terminal] Failed to open URL:", url, error); + onUrlClick: (event, url) => { + if (event.shiftKey) { + electronTrpcClient.external.openUrl.mutate(url).catch((error) => { + console.error("[v2 Terminal] Failed to open URL:", url, error); + }); + return; + } + ctx.store.getState().openPane({ + pane: { + kind: "browser", + data: { url } satisfies BrowserPaneData, + }, }); }, }); - }, [terminalId, workspaceId, onOpenFile, onRevealPath, openInExternalEditor]); + }, [ + terminalId, + workspaceId, + ctx.store, + onOpenFile, + onRevealPath, + openInExternalEditor, + ]); useHotkey( "CLEAR_TERMINAL", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 7bb5940425b..25faa8d4de2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -190,7 +190,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { ); }); }, - onUrlClick: (uri) => { + onUrlClick: (_event, uri) => { const handler = urlClickRef?.current; if (handler) { handler(uri); From 6d88f92b4acef1fd3f0ed2b8fd4fd96a9cfdc13f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 15:56:43 -0700 Subject: [PATCH 09/12] feat(desktop): v2 terminal shows hover tooltip describing cmd-click action Adds onLinkHover/onLinkLeave callbacks to TerminalLinkHandlers, wired through LinkDetectorAdapter, UrlLinkProvider, and WordLinkDetector so every detected link participates. In v2 TerminalPane, a new LinkHoverTooltip component tracks hover + live modifier state (global keydown/keyup listeners scoped to hover duration) and portals a positioned tooltip to document.body when meta/ctrl is held. Content flips on shift: - File: Open in editor | shift: Open externally - Folder: Reveal in sidebar | shift: Open externally - URL: Open in browser | shift: Open in external browser v1's helpers.ts doesn't opt into the new callbacks, so v1 hover behavior is unchanged. --- .../terminal/links/link-detector-adapter.ts | 14 ++++ .../lib/terminal/links/word-link-detector.ts | 11 +++ .../lib/terminal/terminal-link-manager.ts | 36 +++++++- .../lib/terminal/terminal-runtime-registry.ts | 3 +- .../components/TerminalPane/TerminalPane.tsx | 14 ++++ .../LinkHoverTooltip/LinkHoverTooltip.tsx | 84 +++++++++++++++++++ .../components/LinkHoverTooltip/index.ts | 1 + .../multi-line-link-provider.ts | 10 +++ .../link-providers/url-link-provider.ts | 10 +++ 9 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts diff --git a/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.ts b/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.ts index f15e5e479ff..7f5da9edc2f 100644 --- a/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.ts +++ b/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.ts @@ -45,6 +45,8 @@ export class LinkDetectorAdapter implements ILinkProvider { event: MouseEvent, link: DetectedLink, ) => void, + private readonly _onHover?: (event: MouseEvent, link: DetectedLink) => void, + private readonly _onLeave?: () => void, ) {} provideLinks( @@ -192,6 +194,12 @@ export class LinkDetectorAdapter implements ILinkProvider { activate: (event: MouseEvent) => { this._onActivate?.(event, detected); }, + hover: (event: MouseEvent) => { + this._onHover?.(event, detected); + }, + leave: () => { + this._onLeave?.(); + }, }); } } @@ -233,6 +241,12 @@ export class LinkDetectorAdapter implements ILinkProvider { activate: (event: MouseEvent) => { this._onActivate?.(event, detected); }, + hover: (event: MouseEvent) => { + this._onHover?.(event, detected); + }, + leave: () => { + this._onLeave?.(); + }, }); } diff --git a/apps/desktop/src/renderer/lib/terminal/links/word-link-detector.ts b/apps/desktop/src/renderer/lib/terminal/links/word-link-detector.ts index 6d45346aa22..7e49bd9217e 100644 --- a/apps/desktop/src/renderer/lib/terminal/links/word-link-detector.ts +++ b/apps/desktop/src/renderer/lib/terminal/links/word-link-detector.ts @@ -52,6 +52,11 @@ export class WordLinkDetector implements ILinkProvider { event: MouseEvent, resolvedPath: string, ) => void, + private readonly _onHover?: ( + event: MouseEvent, + resolvedPath: string, + ) => void, + private readonly _onLeave?: () => void, ) { this._separatorRegex = buildSeparatorRegex(DEFAULT_WORD_SEPARATORS); } @@ -108,6 +113,12 @@ export class WordLinkDetector implements ILinkProvider { activate: (event: MouseEvent) => { this._onActivate?.(event, resolved.path); }, + hover: (event: MouseEvent) => { + this._onHover?.(event, resolved.path); + }, + leave: () => { + this._onLeave?.(); + }, }); } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts index cdce4f79d10..ea1ca56fb91 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts @@ -18,6 +18,10 @@ import { WordLinkDetector, } from "./links"; +export type LinkHoverInfo = + | { kind: "file"; isDirectory: boolean } + | { kind: "url" }; + /** * Link handler callbacks for the v2 terminal. */ @@ -26,6 +30,10 @@ export interface TerminalLinkHandlers { onFileLinkClick?: (event: MouseEvent, link: DetectedLink) => void; /** Called when a URL link is activated. */ onUrlClick?: (event: MouseEvent, url: string) => void; + /** Called when the mouse enters a detected link (file path or URL). */ + onLinkHover?: (event: MouseEvent, info: LinkHoverInfo) => void; + /** Called when the mouse leaves a previously hovered link. */ + onLinkLeave?: () => void; /** * Stat callback to validate file paths exist. Called via the host service * which handles all path resolution (relative, tilde, etc.) server-side. @@ -93,21 +101,39 @@ export class TerminalLinkManager { this._resolver = new TerminalLinkResolver(handlers.stat); } + const onLinkHover = handlers.onLinkHover; + const onLinkLeave = handlers.onLinkLeave; + // 1. File path detector (highest priority) const detector = new LocalLinkDetector(this._resolver); const adapter = new LinkDetectorAdapter( this._terminal, detector, handlers.onFileLinkClick, + onLinkHover + ? (event, link) => + onLinkHover(event, { + kind: "file", + isDirectory: link.isDirectory, + }) + : undefined, + onLinkLeave, ); this._disposables.push(this._terminal.registerLinkProvider(adapter)); // 2. URL link provider (handles hard-wrapped URLs) if (handlers.onUrlClick) { const onUrlClick = handlers.onUrlClick; - const urlProvider = new UrlLinkProvider(this._terminal, (event, uri) => { - onUrlClick(event, uri); - }); + const urlProvider = new UrlLinkProvider( + this._terminal, + (event, uri) => { + onUrlClick(event, uri); + }, + onLinkHover + ? (event) => onLinkHover(event, { kind: "url" }) + : undefined, + onLinkLeave, + ); this._disposables.push(this._terminal.registerLinkProvider(urlProvider)); } @@ -135,6 +161,10 @@ export class TerminalLinkManager { colEnd: undefined, }); }, + onLinkHover + ? (event) => onLinkHover(event, { kind: "file", isDirectory: false }) + : undefined, + onLinkLeave, ); this._disposables.push(this._terminal.registerLinkProvider(wordDetector)); } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 627c99e8542..0e56d579f09 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -2,6 +2,7 @@ import type { ProgressAddon } from "@xterm/addon-progress"; import type { SearchAddon } from "@xterm/addon-search"; import type { TerminalAppearance } from "./appearance"; import { + type LinkHoverInfo, type TerminalLinkHandlers, TerminalLinkManager, } from "./terminal-link-manager"; @@ -216,4 +217,4 @@ if (import.meta.hot) { import.meta.hot.data.registry = terminalRuntimeRegistry; } -export type { ConnectionState, TerminalLinkHandlers }; +export type { ConnectionState, LinkHoverInfo, TerminalLinkHandlers }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index c2a40a27ace..d30d1bd3e2e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -25,6 +25,10 @@ import { ScrollToBottomButton } from "renderer/screens/main/components/Workspace import { TerminalSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch"; import { useTheme } from "renderer/stores/theme"; import { resolveTerminalThemeType } from "renderer/stores/theme/utils"; +import { + LinkHoverTooltip, + useLinkHoverState, +} from "./components/LinkHoverTooltip"; import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; interface TerminalPaneProps { @@ -50,6 +54,11 @@ export function TerminalPane({ onRevealPath, }: TerminalPaneProps) { const openInExternalEditor = useOpenInExternalEditor(workspaceId); + const { + hoveredLink, + onHover: onLinkHover, + onLeave: onLinkLeave, + } = useLinkHoverState(); const paneData = ctx.pane.data as TerminalPaneData; const { terminalId } = paneData; const containerRef = useRef(null); @@ -180,6 +189,8 @@ export function TerminalPane({ }, }); }, + onLinkHover, + onLinkLeave, }); }, [ terminalId, @@ -188,6 +199,8 @@ export function TerminalPane({ onOpenFile, onRevealPath, openInExternalEditor, + onLinkHover, + onLinkLeave, ]); useHotkey( @@ -244,6 +257,7 @@ export function TerminalPane({ Disconnected )} + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx new file mode 100644 index 00000000000..d42b9257152 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry"; + +interface HoveredLink { + clientX: number; + clientY: number; + info: LinkHoverInfo; + modifier: boolean; + shift: boolean; +} + +interface LinkHoverTooltipProps { + hoveredLink: HoveredLink | null; +} + +function getLabel(info: LinkHoverInfo, shift: boolean): string { + if (info.kind === "url") { + return shift ? "Open in external browser" : "Open in browser"; + } + if (info.isDirectory) { + return shift ? "Open externally" : "Reveal in sidebar"; + } + return shift ? "Open externally" : "Open in editor"; +} + +export function LinkHoverTooltip({ hoveredLink }: LinkHoverTooltipProps) { + if (!hoveredLink || !hoveredLink.modifier) return null; + + const label = getLabel(hoveredLink.info, hoveredLink.shift); + + return createPortal( +
+ {label} +
, + document.body, + ); +} + +export function useLinkHoverState() { + const [hoveredLink, setHoveredLink] = useState(null); + + useEffect(() => { + if (!hoveredLink) return; + const update = (event: KeyboardEvent) => { + setHoveredLink((prev) => { + if (!prev) return null; + return { + ...prev, + modifier: event.metaKey || event.ctrlKey, + shift: event.shiftKey, + }; + }); + }; + window.addEventListener("keydown", update); + window.addEventListener("keyup", update); + return () => { + window.removeEventListener("keydown", update); + window.removeEventListener("keyup", update); + }; + }, [hoveredLink]); + + const onHover = useCallback((event: MouseEvent, info: LinkHoverInfo) => { + setHoveredLink({ + clientX: event.clientX, + clientY: event.clientY, + info, + modifier: event.metaKey || event.ctrlKey, + shift: event.shiftKey, + }); + }, []); + + const onLeave = useCallback(() => { + setHoveredLink(null); + }, []); + + return { hoveredLink, onHover, onLeave }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts new file mode 100644 index 00000000000..20a5829fbc2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts @@ -0,0 +1 @@ +export { LinkHoverTooltip, useLinkHoverState } from "./LinkHoverTooltip"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts index 2521bd79aec..099ad5b0f7e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts @@ -42,6 +42,10 @@ export abstract class MultiLineLinkProvider implements ILinkProvider { regexMatch: RegExpMatchArray, ): void; + /** Optional hooks fired when the mouse enters/leaves a detected link. */ + protected handleHover?(event: MouseEvent, text: string): void; + protected handleLeave?(): void; + /** * Optional hook to transform a match before creating the link. * Useful for stripping trailing characters. Return null to skip the match. @@ -177,6 +181,12 @@ export abstract class MultiLineLinkProvider implements ILinkProvider { activate: (event: MouseEvent, text: string) => { this.handleActivation(event, text, match); }, + hover: (event: MouseEvent, text: string) => { + this.handleHover?.(event, text); + }, + leave: () => { + this.handleLeave?.(); + }, }); } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts index c42aab75b12..66718c2fb88 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts @@ -313,10 +313,20 @@ export class UrlLinkProvider extends MultiLineLinkProvider { constructor( terminal: Terminal, private readonly onOpen: (event: MouseEvent, uri: string) => void, + private readonly onHover?: (event: MouseEvent, uri: string) => void, + private readonly onLeave?: () => void, ) { super(terminal); } + protected handleHover(event: MouseEvent, text: string): void { + this.onHover?.(event, text); + } + + protected handleLeave(): void { + this.onLeave?.(); + } + protected getPattern(): RegExp { return new RegExp(this.URL_PATTERN.source, "g"); } From f19b455cce4f7ccccdcdf3eabb2c70722246dee5 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 16:43:11 -0700 Subject: [PATCH 10/12] refactor(desktop): match tooltip styling, surface configured editor name - Tooltip now uses the same bg-foreground/text-background/rounded-md/px-3/py-1.5/text-xs tokens as the project's TooltipContent, so it visually matches the rest of the app (was a custom border/popover style before). - Shift-modifier tooltip text now says "Open in Cursor" / "Open in VS Code" / etc. based on the user's configured defaultEditor setting, resolved via electronTrpcClient.settings.getDefaultEditor + getAppOption display label. Falls back to "Open externally" if no editor is configured. --- .../LinkHoverTooltip/LinkHoverTooltip.tsx | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx index d42b9257152..f4639efab55 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx @@ -1,6 +1,9 @@ +import type { ExternalApp } from "@superset/local-db"; import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; +import { getAppOption } from "renderer/components/OpenInExternalDropdown/constants"; import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; interface HoveredLink { clientX: number; @@ -14,24 +17,52 @@ interface LinkHoverTooltipProps { hoveredLink: HoveredLink | null; } -function getLabel(info: LinkHoverInfo, shift: boolean): string { +function getAppLabel(app: ExternalApp): string { + const option = getAppOption(app); + return option?.displayLabel ?? option?.label ?? "external editor"; +} + +function getLabel( + info: LinkHoverInfo, + shift: boolean, + defaultEditor: ExternalApp | null, +): string { if (info.kind === "url") { return shift ? "Open in external browser" : "Open in browser"; } - if (info.isDirectory) { - return shift ? "Open externally" : "Reveal in sidebar"; + if (shift) { + return defaultEditor + ? `Open in ${getAppLabel(defaultEditor)}` + : "Open externally"; } - return shift ? "Open externally" : "Open in editor"; + return info.isDirectory ? "Reveal in sidebar" : "Open in editor"; } export function LinkHoverTooltip({ hoveredLink }: LinkHoverTooltipProps) { + const [defaultEditor, setDefaultEditor] = useState(null); + + useEffect(() => { + let cancelled = false; + electronTrpcClient.settings.getDefaultEditor + .query() + .then((editor) => { + if (!cancelled) setDefaultEditor(editor); + }) + .catch(() => { + if (!cancelled) setDefaultEditor(null); + }); + return () => { + cancelled = true; + }; + }, []); + if (!hoveredLink || !hoveredLink.modifier) return null; - const label = getLabel(hoveredLink.info, hoveredLink.shift); + const label = getLabel(hoveredLink.info, hoveredLink.shift, defaultEditor); return createPortal(
Date: Fri, 17 Apr 2026 16:52:24 -0700 Subject: [PATCH 11/12] refactor(desktop): split LinkHoverTooltip hook/component, tighten modifier listener - Move useLinkHoverState into its own hooks/ folder (was exported alongside the component in violation of the one-component-per-file rule). - Effect now re-subscribes on hover start/end only (deps: hovering boolean), not on every modifier change. - Filter to Meta/Control/Shift/Alt key events so typing a letter while hovering doesn't churn state. - Skip setState when modifier/shift values didn't actually change, avoiding identity-change re-renders on repeat keydowns. - Extract tooltip offset constant. --- .../components/TerminalPane/TerminalPane.tsx | 6 +- .../LinkHoverTooltip/LinkHoverTooltip.tsx | 55 ++----------------- .../components/LinkHoverTooltip/index.ts | 2 +- .../hooks/useLinkHoverState/index.ts | 1 + .../useLinkHoverState/useLinkHoverState.ts | 55 +++++++++++++++++++ 5 files changed, 64 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/useLinkHoverState.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index d30d1bd3e2e..c8b9332f22d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -25,10 +25,8 @@ import { ScrollToBottomButton } from "renderer/screens/main/components/Workspace import { TerminalSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch"; import { useTheme } from "renderer/stores/theme"; import { resolveTerminalThemeType } from "renderer/stores/theme/utils"; -import { - LinkHoverTooltip, - useLinkHoverState, -} from "./components/LinkHoverTooltip"; +import { LinkHoverTooltip } from "./components/LinkHoverTooltip"; +import { useLinkHoverState } from "./hooks/useLinkHoverState"; import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; interface TerminalPaneProps { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx index f4639efab55..1974c8cb572 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx @@ -1,17 +1,12 @@ import type { ExternalApp } from "@superset/local-db"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { getAppOption } from "renderer/components/OpenInExternalDropdown/constants"; import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry"; import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { HoveredLink } from "../../hooks/useLinkHoverState"; -interface HoveredLink { - clientX: number; - clientY: number; - info: LinkHoverInfo; - modifier: boolean; - shift: boolean; -} +const TOOLTIP_OFFSET_PX = 14; interface LinkHoverTooltipProps { hoveredLink: HoveredLink | null; @@ -64,8 +59,8 @@ export function LinkHoverTooltip({ hoveredLink }: LinkHoverTooltipProps) {
{label} @@ -73,43 +68,3 @@ export function LinkHoverTooltip({ hoveredLink }: LinkHoverTooltipProps) { document.body, ); } - -export function useLinkHoverState() { - const [hoveredLink, setHoveredLink] = useState(null); - - useEffect(() => { - if (!hoveredLink) return; - const update = (event: KeyboardEvent) => { - setHoveredLink((prev) => { - if (!prev) return null; - return { - ...prev, - modifier: event.metaKey || event.ctrlKey, - shift: event.shiftKey, - }; - }); - }; - window.addEventListener("keydown", update); - window.addEventListener("keyup", update); - return () => { - window.removeEventListener("keydown", update); - window.removeEventListener("keyup", update); - }; - }, [hoveredLink]); - - const onHover = useCallback((event: MouseEvent, info: LinkHoverInfo) => { - setHoveredLink({ - clientX: event.clientX, - clientY: event.clientY, - info, - modifier: event.metaKey || event.ctrlKey, - shift: event.shiftKey, - }); - }, []); - - const onLeave = useCallback(() => { - setHoveredLink(null); - }, []); - - return { hoveredLink, onHover, onLeave }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts index 20a5829fbc2..8be6f8ce6fd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts @@ -1 +1 @@ -export { LinkHoverTooltip, useLinkHoverState } from "./LinkHoverTooltip"; +export { LinkHoverTooltip } from "./LinkHoverTooltip"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/index.ts new file mode 100644 index 00000000000..086ba38be7e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/index.ts @@ -0,0 +1 @@ +export { type HoveredLink, useLinkHoverState } from "./useLinkHoverState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/useLinkHoverState.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/useLinkHoverState.ts new file mode 100644 index 00000000000..e635d62331a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/useLinkHoverState.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from "react"; +import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry"; + +export interface HoveredLink { + clientX: number; + clientY: number; + info: LinkHoverInfo; + modifier: boolean; + shift: boolean; +} + +const MODIFIER_KEYS = new Set(["Meta", "Control", "Shift", "Alt"]); + +export function useLinkHoverState() { + const [hoveredLink, setHoveredLink] = useState(null); + const hovering = hoveredLink !== null; + + useEffect(() => { + if (!hovering) return; + const update = (event: KeyboardEvent) => { + if (!MODIFIER_KEYS.has(event.key)) return; + setHoveredLink((prev) => { + if (!prev) return null; + const nextModifier = event.metaKey || event.ctrlKey; + const nextShift = event.shiftKey; + if (prev.modifier === nextModifier && prev.shift === nextShift) { + return prev; + } + return { ...prev, modifier: nextModifier, shift: nextShift }; + }); + }; + window.addEventListener("keydown", update); + window.addEventListener("keyup", update); + return () => { + window.removeEventListener("keydown", update); + window.removeEventListener("keyup", update); + }; + }, [hovering]); + + const onHover = useCallback((event: MouseEvent, info: LinkHoverInfo) => { + setHoveredLink({ + clientX: event.clientX, + clientY: event.clientY, + info, + modifier: event.metaKey || event.ctrlKey, + shift: event.shiftKey, + }); + }, []); + + const onLeave = useCallback(() => { + setHoveredLink(null); + }, []); + + return { hoveredLink, onHover, onLeave }; +} From ce8bfa3525db0bef28224cefa7270d9ae62a6658 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 16:57:49 -0700 Subject: [PATCH 12/12] fix(desktop): block external open while host data loads, surface editor-query failures - useOpenInExternalEditor now treats an unloaded workspaceHost as non-local (workspaceHost?.hostMachineId !== machineId) so Cmd+Shift-click doesn't fire the mutation against a potentially remote workspace before locality is confirmed. - LinkHoverTooltip's getDefaultEditor catch now console.warn's the error before falling back to null, so settings RPC failures stay observable. --- .../useOpenInExternalEditor/useOpenInExternalEditor.ts | 4 +++- .../components/LinkHoverTooltip/LinkHoverTooltip.tsx | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts index 6a55d915fe9..d5517584455 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts @@ -31,7 +31,9 @@ export function useOpenInExternalEditor(workspaceId: string) { return useCallback( (path: string, opts?: OpenInExternalEditorOptions) => { - if (workspaceHost && workspaceHost.hostMachineId !== machineId) { + // Treat unloaded host data as non-local to avoid firing the mutation + // against a potentially remote workspace before locality is confirmed. + if (workspaceHost?.hostMachineId !== machineId) { toast.error("Can't open remote workspace paths in an external editor"); return; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx index 1974c8cb572..87323399029 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx @@ -43,8 +43,13 @@ export function LinkHoverTooltip({ hoveredLink }: LinkHoverTooltipProps) { .then((editor) => { if (!cancelled) setDefaultEditor(editor); }) - .catch(() => { - if (!cancelled) setDefaultEditor(null); + .catch((error) => { + if (cancelled) return; + console.warn( + "[LinkHoverTooltip] Failed to fetch default editor:", + error, + ); + setDefaultEditor(null); }); return () => { cancelled = true;