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/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 c045142cc0a..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. */ @@ -25,7 +29,11 @@ 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; + /** 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(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 845c7f6690f..df890d5d50f 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"; @@ -224,4 +225,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/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..d5517584455 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts @@ -0,0 +1,49 @@ +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) => { + // 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; + } + 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 1f97c38c37e..0f4190f95ac 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 { @@ -15,7 +14,9 @@ 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 { + BrowserPaneData, PaneViewerData, TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; @@ -24,11 +25,16 @@ 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 } from "./components/LinkHoverTooltip"; +import { useLinkHoverState } from "./hooks/useLinkHoverState"; import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; +import { shellEscapePaths } from "./utils"; interface TerminalPaneProps { ctx: RendererContext; workspaceId: string; + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string) => void; } function subscribeToState(terminalId: string) { @@ -40,7 +46,18 @@ function getConnectionState(terminalId: string): ConnectionState { return terminalRuntimeRegistry.getConnectionState(terminalId); } -export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { +export function TerminalPane({ + ctx, + workspaceId, + onOpenFile, + onRevealPath, +}: TerminalPaneProps) { + const openInExternalEditor = useOpenInExternalEditor(workspaceId); + const { + hoveredLink, + onHover: onLinkHover, + onLeave: onLinkLeave, + } = useLinkHoverState(); const paneData = ctx.pane.data as TerminalPaneData; // FORK NOTE: Guard against legacy pane data format {sessionKey, cwd, launchMode} // saved in local DB before the terminalId migration. @@ -147,27 +164,51 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { return null; } }, - onFileLinkClick: (_event, link) => { - if (!_event.metaKey && !_event.ctrlKey) return; - _event.preventDefault(); - electronTrpcClient.external.openFileInEditor - .mutate({ - path: link.resolvedPath, + onFileLinkClick: (event, link) => { + if (!event.metaKey && !event.ctrlKey) return; + event.preventDefault(); + if (event.shiftKey) { + openInExternalEditor(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) { + onRevealPath(link.resolvedPath); + } else { + 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", + // FORK NOTE: fork BrowserPaneData requires `mode`; default to + // "generic" for terminal-link-opened URLs. + data: { url, mode: "generic" } satisfies BrowserPaneData, + }, }); }, + onLinkHover, + onLinkLeave, }); - }, [terminalId, workspaceId]); + }, [ + terminalId, + workspaceId, + ctx.store, + onOpenFile, + onRevealPath, + openInExternalEditor, + onLinkHover, + onLinkLeave, + ]); useHotkey( "CLEAR_TERMINAL", @@ -203,8 +244,61 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { [terminalId, connectionState], ); + const [isDropActive, setIsDropActive] = useState(false); + const dragCounterRef = useRef(0); + + const resolveDroppedText = (dataTransfer: DataTransfer): string | null => { + const files = Array.from(dataTransfer.files); + if (files.length > 0) { + const paths = files + .map((file) => window.webUtils.getPathForFile(file)) + .filter(Boolean); + return paths.length > 0 ? shellEscapePaths(paths) : null; + } + const plainText = dataTransfer.getData("text/plain"); + return plainText ? shellEscapePaths([plainText]) : null; + }; + + const handleDragEnter = (event: React.DragEvent) => { + event.preventDefault(); + dragCounterRef.current += 1; + setIsDropActive(true); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + dragCounterRef.current -= 1; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setIsDropActive(false); + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + dragCounterRef.current = 0; + setIsDropActive(false); + if (connectionState === "closed") return; + const text = resolveDroppedText(event.dataTransfer); + if (!text) return; + terminalRuntimeRegistry.getTerminal(terminalId)?.focus(); + terminalRuntimeRegistry.paste(terminalId, text); + }; + return ( -
+
+ {isDropActive && ( +
+ )}
{connectionState === "closed" && (
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..87323399029 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx @@ -0,0 +1,75 @@ +import type { ExternalApp } from "@superset/local-db"; +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"; + +const TOOLTIP_OFFSET_PX = 14; + +interface LinkHoverTooltipProps { + hoveredLink: HoveredLink | null; +} + +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 (shift) { + return defaultEditor + ? `Open in ${getAppLabel(defaultEditor)}` + : "Open externally"; + } + 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((error) => { + if (cancelled) return; + console.warn( + "[LinkHoverTooltip] Failed to fetch default editor:", + error, + ); + setDefaultEditor(null); + }); + return () => { + cancelled = true; + }; + }, []); + + if (!hoveredLink || !hoveredLink.modifier) return null; + + const label = getLabel(hoveredLink.info, hoveredLink.shift, defaultEditor); + + return createPortal( +
+ {label} +
, + document.body, + ); +} 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..8be6f8ce6fd --- /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 } 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 }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/utils.ts new file mode 100644 index 00000000000..e27ba49361f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/utils.ts @@ -0,0 +1,5 @@ +import { quote } from "shell-quote"; + +export function shellEscapePaths(paths: string[]): string { + return quote(paths); +} 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 83b0bc3fa4d..2332d09580e 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 @@ -198,8 +198,14 @@ function DiffViewModeToggle() { ); } +interface UsePaneRegistryOptions { + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string) => void; +} + export function usePaneRegistry( workspaceId: string, + { onOpenFile, onRevealPath }: UsePaneRegistryOptions, ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; @@ -296,7 +302,12 @@ export function usePaneRegistry( getIcon: () => , getTitle: () => "Terminal", renderPane: (ctx: RendererContext) => ( - + ), contextMenuActions: (_ctx, defaults) => { const terminalActions: ContextMenuActionConfig[] = [ @@ -470,6 +481,12 @@ export function usePaneRegistry( }, }, }), - [workspaceId, clearShortcut, scrollToBottomShortcut], + [ + workspaceId, + clearShortcut, + scrollToBottomShortcut, + onOpenFile, + onRevealPath, + ], ); } 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 7f86a8f7a8c..b8b762d74d7 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 @@ -15,7 +15,7 @@ import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import { useRightSidebarOpenViewWidth } from "renderer/hooks/useRightSidebarOpenViewWidth"; @@ -154,8 +154,7 @@ function WorkspaceContent({ projectId, }); useConsumePendingLaunch({ workspaceId, store }); - const paneRegistry = usePaneRegistry(workspaceId); - const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); + const collections = useCollections(); const rightSidebarOpenViewWidth = useRightSidebarOpenViewWidth(); const utils = electronTrpc.useUtils(); const { data: showPresetsBar } = @@ -195,7 +194,7 @@ function WorkspaceContent({ [recordView, worktreePath], ); - const selectedFilePath = useStore(store, (s) => { + 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]; @@ -203,6 +202,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) => @@ -335,6 +344,33 @@ function WorkspaceContent({ [rightSidebarOpenViewWidth, store, recordRecentlyViewed], ); + const revealPath = useCallback( + (path: string) => { + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.rightSidebarOpen = true; + draft.sidebarState.activeTab = "files"; + }); + setSelectedFilePath(path); + }, + [collections, workspaceId], + ); + + // FORK NOTE: fork's openFilePane takes (filePath, displayName?) for the + // memo-title path; usePaneRegistry's onOpenFile contract is + // (path, openInNewTab?). Bind without forwarding the 2nd arg so the types + // line up — terminal Cmd+click just opens in the active tab — and wrap + // with useCallback so paneRegistry's memo stays stable. + const handleTerminalOpenFile = useCallback( + (filePath: string) => openFilePane(filePath), + [openFilePane], + ); + + const paneRegistry = usePaneRegistry(workspaceId, { + onOpenFile: handleTerminalOpenFile, + onRevealPath: revealPath, + }); + const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); + const openDiffPane = useCallback( (filePath: string) => { const state = store.getState(); 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 d5102a0b7ee..3626bef575e 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); 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"); }