diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx index fcbe0328e3..9d3980c69e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx @@ -10,8 +10,11 @@ import { ContextMenuTrigger, } from "@superset/ui/context-menu"; import type { ReactNode } from "react"; +import { useState } from "react"; import { LuArrowDownToLine, + LuClipboard, + LuClipboardCopy, LuColumns2, LuEraser, LuMoveRight, @@ -22,6 +25,11 @@ import { import { useHotkeyText } from "renderer/stores/hotkeys"; import type { Tab } from "renderer/stores/tabs/types"; +function getModifierKeyLabel() { + const isMac = navigator.platform.toLowerCase().includes("mac"); + return isMac ? "⌘" : "Ctrl+"; +} + interface TabContentContextMenuProps { children: ReactNode; onSplitHorizontal: () => void; @@ -29,6 +37,8 @@ interface TabContentContextMenuProps { onClosePane: () => void; onClearTerminal: () => void; onScrollToBottom: () => void; + getSelection?: () => string; + onPaste?: (text: string) => void; currentTabId: string; availableTabs: Tab[]; onMoveToTab: (tabId: string) => void; @@ -42,6 +52,8 @@ export function TabContentContextMenu({ onClosePane, onClearTerminal, onScrollToBottom, + getSelection, + onPaste, currentTabId, availableTabs, onMoveToTab, @@ -53,11 +65,57 @@ export function TabContentContextMenu({ const showClearShortcut = clearShortcut !== "Unassigned"; const scrollToBottomShortcut = useHotkeyText("SCROLL_TO_BOTTOM"); const showScrollToBottomShortcut = scrollToBottomShortcut !== "Unassigned"; + const modKey = getModifierKeyLabel(); + + const [hasSelection, setHasSelection] = useState(false); + const [hasClipboard, setHasClipboard] = useState(false); + + const handleOpenChange = async (open: boolean) => { + if (!open) return; + setHasSelection(!!getSelection?.()?.length); + try { + const text = await navigator.clipboard.readText(); + setHasClipboard(!!text); + } catch { + setHasClipboard(false); + } + }; + + const handleCopy = async () => { + const text = getSelection?.(); + if (!text) return; + await navigator.clipboard.writeText(text); + }; + + const handlePaste = async () => { + if (!onPaste) return; + try { + const text = await navigator.clipboard.readText(); + if (text) onPaste(text); + } catch { + // Clipboard access denied + } + }; return ( - + {children} + {getSelection && ( + + + Copy + {modKey}C + + )} + {onPaste && ( + + + Paste + {modKey}V + + )} + {(getSelection || onPaste) && } Split Horizontally diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index 7c23da3386..8ab5b61254 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -64,6 +64,10 @@ export function TabPane({ const getScrollToBottomCallback = useTerminalCallbacksStore( (s) => s.getScrollToBottomCallback, ); + const getGetSelectionCallback = useTerminalCallbacksStore( + (s) => s.getGetSelectionCallback, + ); + const getPasteCallback = useTerminalCallbacksStore((s) => s.getPasteCallback); useEffect(() => { const container = terminalContainerRef.current; @@ -117,6 +121,8 @@ export function TabPane({ onClosePane={() => removePane(paneId)} onClearTerminal={handleClearTerminal} onScrollToBottom={handleScrollToBottom} + getSelection={() => getGetSelectionCallback(paneId)?.() ?? ""} + onPaste={(text) => getPasteCallback(paneId)?.(text)} currentTabId={tabId} availableTabs={availableTabs} onMoveToTab={onMoveToTab} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 5961cf8114..7ef4a0599a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -149,6 +149,10 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { unregisterClearCallbackRef, registerScrollToBottomCallbackRef, unregisterScrollToBottomCallbackRef, + registerGetSelectionCallbackRef, + unregisterGetSelectionCallbackRef, + registerPasteCallbackRef, + unregisterPasteCallbackRef, } = useTerminalRefs({ paneId, tabId, @@ -299,6 +303,10 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { unregisterClearCallbackRef, registerScrollToBottomCallbackRef, unregisterScrollToBottomCallbackRef, + registerGetSelectionCallbackRef, + unregisterGetSelectionCallbackRef, + registerPasteCallbackRef, + unregisterPasteCallbackRef, }); useEffect(() => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index ca554277cf..f9a4703da1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -124,6 +124,14 @@ export interface UseTerminalLifecycleOptions { unregisterClearCallbackRef: MutableRefObject; registerScrollToBottomCallbackRef: MutableRefObject; unregisterScrollToBottomCallbackRef: MutableRefObject; + registerGetSelectionCallbackRef: MutableRefObject< + (paneId: string, callback: () => string) => void + >; + unregisterGetSelectionCallbackRef: MutableRefObject; + registerPasteCallbackRef: MutableRefObject< + (paneId: string, callback: (text: string) => void) => void + >; + unregisterPasteCallbackRef: MutableRefObject; } export interface UseTerminalLifecycleReturn { @@ -176,6 +184,10 @@ export function useTerminalLifecycle({ unregisterClearCallbackRef, registerScrollToBottomCallbackRef, unregisterScrollToBottomCallbackRef, + registerGetSelectionCallbackRef, + unregisterGetSelectionCallbackRef, + registerPasteCallbackRef, + unregisterPasteCallbackRef, }: UseTerminalLifecycleOptions): UseTerminalLifecycleReturn { const [xtermInstance, setXtermInstance] = useState(null); const restartTerminalRef = useRef<() => void>(() => {}); @@ -490,6 +502,23 @@ export function useTerminalLifecycle({ registerClearCallbackRef.current(paneId, handleClear); registerScrollToBottomCallbackRef.current(paneId, handleScrollToBottom); + const handleGetSelection = () => { + const selection = xterm.getSelection(); + if (!selection) return ""; + return selection + .split("\n") + .map((line) => line.trimEnd()) + .join("\n"); + }; + + const handlePaste = (text: string) => { + if (isExitedRef.current) return; + xterm.paste(text); + }; + + registerGetSelectionCallbackRef.current(paneId, handleGetSelection); + registerPasteCallbackRef.current(paneId, handlePaste); + const cleanupFocus = setupFocusListener(xterm, () => handleTerminalFocusRef.current(), ); @@ -558,6 +587,8 @@ export function useTerminalLifecycle({ cleanupQuerySuppression(); unregisterClearCallbackRef.current(paneId); unregisterScrollToBottomCallbackRef.current(paneId); + unregisterGetSelectionCallbackRef.current(paneId); + unregisterPasteCallbackRef.current(paneId); if (isPaneDestroyedInStore()) { // Pane was explicitly destroyed, so kill the session. diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts index 51f08110dd..c4e17254c3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts @@ -4,6 +4,14 @@ import { useRef } from "react"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; type RegisterCallback = (paneId: string, callback: () => void) => void; +type RegisterGetSelectionCallback = ( + paneId: string, + callback: () => string, +) => void; +type RegisterPasteCallback = ( + paneId: string, + callback: (text: string) => void, +) => void; type UnregisterCallback = (paneId: string) => void; export interface UseTerminalRefsOptions { @@ -37,6 +45,10 @@ export interface UseTerminalRefsReturn { unregisterClearCallbackRef: MutableRefObject; registerScrollToBottomCallbackRef: MutableRefObject; unregisterScrollToBottomCallbackRef: MutableRefObject; + registerGetSelectionCallbackRef: MutableRefObject; + unregisterGetSelectionCallbackRef: MutableRefObject; + registerPasteCallbackRef: MutableRefObject; + unregisterPasteCallbackRef: MutableRefObject; } export function useTerminalRefs({ @@ -90,6 +102,18 @@ export function useTerminalRefs({ const unregisterScrollToBottomCallbackRef = useRef( useTerminalCallbacksStore.getState().unregisterScrollToBottomCallback, ); + const registerGetSelectionCallbackRef = useRef( + useTerminalCallbacksStore.getState().registerGetSelectionCallback, + ); + const unregisterGetSelectionCallbackRef = useRef( + useTerminalCallbacksStore.getState().unregisterGetSelectionCallback, + ); + const registerPasteCallbackRef = useRef( + useTerminalCallbacksStore.getState().registerPasteCallback, + ); + const unregisterPasteCallbackRef = useRef( + useTerminalCallbacksStore.getState().unregisterPasteCallback, + ); return { isFocused, @@ -106,5 +130,9 @@ export function useTerminalRefs({ unregisterClearCallbackRef, registerScrollToBottomCallbackRef, unregisterScrollToBottomCallbackRef, + registerGetSelectionCallbackRef, + unregisterGetSelectionCallbackRef, + registerPasteCallbackRef, + unregisterPasteCallbackRef, }; } diff --git a/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts b/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts index e54a460bf8..96e4b4f064 100644 --- a/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts +++ b/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts @@ -3,6 +3,8 @@ import { create } from "zustand"; interface TerminalCallbacksState { clearCallbacks: Map void>; scrollToBottomCallbacks: Map void>; + getSelectionCallbacks: Map string>; + pasteCallbacks: Map void>; registerClearCallback: (paneId: string, callback: () => void) => void; unregisterClearCallback: (paneId: string) => void; getClearCallback: (paneId: string) => (() => void) | undefined; @@ -12,12 +14,26 @@ interface TerminalCallbacksState { ) => void; unregisterScrollToBottomCallback: (paneId: string) => void; getScrollToBottomCallback: (paneId: string) => (() => void) | undefined; + registerGetSelectionCallback: ( + paneId: string, + callback: () => string, + ) => void; + unregisterGetSelectionCallback: (paneId: string) => void; + getGetSelectionCallback: (paneId: string) => (() => string) | undefined; + registerPasteCallback: ( + paneId: string, + callback: (text: string) => void, + ) => void; + unregisterPasteCallback: (paneId: string) => void; + getPasteCallback: (paneId: string) => ((text: string) => void) | undefined; } export const useTerminalCallbacksStore = create()( (set, get) => ({ clearCallbacks: new Map(), scrollToBottomCallbacks: new Map(), + getSelectionCallbacks: new Map(), + pasteCallbacks: new Map(), registerClearCallback: (paneId, callback) => { set((state) => { @@ -58,5 +74,45 @@ export const useTerminalCallbacksStore = create()( getScrollToBottomCallback: (paneId) => { return get().scrollToBottomCallbacks.get(paneId); }, + + registerGetSelectionCallback: (paneId, callback) => { + set((state) => { + const newCallbacks = new Map(state.getSelectionCallbacks); + newCallbacks.set(paneId, callback); + return { getSelectionCallbacks: newCallbacks }; + }); + }, + + unregisterGetSelectionCallback: (paneId) => { + set((state) => { + const newCallbacks = new Map(state.getSelectionCallbacks); + newCallbacks.delete(paneId); + return { getSelectionCallbacks: newCallbacks }; + }); + }, + + getGetSelectionCallback: (paneId) => { + return get().getSelectionCallbacks.get(paneId); + }, + + registerPasteCallback: (paneId, callback) => { + set((state) => { + const newCallbacks = new Map(state.pasteCallbacks); + newCallbacks.set(paneId, callback); + return { pasteCallbacks: newCallbacks }; + }); + }, + + unregisterPasteCallback: (paneId) => { + set((state) => { + const newCallbacks = new Map(state.pasteCallbacks); + newCallbacks.delete(paneId); + return { pasteCallbacks: newCallbacks }; + }); + }, + + getPasteCallback: (paneId) => { + return get().pasteCallbacks.get(paneId); + }, }), );