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 25868490187..fcbe0328e32 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 @@ -11,6 +11,7 @@ import { } from "@superset/ui/context-menu"; import type { ReactNode } from "react"; import { + LuArrowDownToLine, LuColumns2, LuEraser, LuMoveRight, @@ -27,6 +28,7 @@ interface TabContentContextMenuProps { onSplitVertical: () => void; onClosePane: () => void; onClearTerminal: () => void; + onScrollToBottom: () => void; currentTabId: string; availableTabs: Tab[]; onMoveToTab: (tabId: string) => void; @@ -39,6 +41,7 @@ export function TabContentContextMenu({ onSplitVertical, onClosePane, onClearTerminal, + onScrollToBottom, currentTabId, availableTabs, onMoveToTab, @@ -48,6 +51,8 @@ export function TabContentContextMenu({ const targetTabs = availableTabs.filter((t) => t.id !== currentTabId); const clearShortcut = useHotkeyText("CLEAR_TERMINAL"); const showClearShortcut = clearShortcut !== "Unassigned"; + const scrollToBottomShortcut = useHotkeyText("SCROLL_TO_BOTTOM"); + const showScrollToBottomShortcut = scrollToBottomShortcut !== "Unassigned"; return ( @@ -68,6 +73,13 @@ export function TabContentContextMenu({ {clearShortcut} )} + + + Scroll to Bottom + {showScrollToBottomShortcut && ( + {scrollToBottomShortcut} + )} + 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 a72f6e168c7..77a85e0dfeb 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 @@ -59,6 +59,9 @@ export function TabPane({ }: TabPaneProps) { const terminalContainerRef = useRef(null); const getClearCallback = useTerminalCallbacksStore((s) => s.getClearCallback); + const getScrollToBottomCallback = useTerminalCallbacksStore( + (s) => s.getScrollToBottomCallback, + ); useEffect(() => { const container = terminalContainerRef.current; @@ -74,6 +77,10 @@ export function TabPane({ getClearCallback(paneId)?.(); }; + const handleScrollToBottom = () => { + getScrollToBottomCallback(paneId)?.(); + }; + return ( splitPaneVertical(tabId, paneId, path)} onClosePane={() => removePane(paneId)} onClearTerminal={handleClearTerminal} + onScrollToBottom={handleScrollToBottom} 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 2f764dfaadd..54b55670b3c 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 @@ -196,6 +196,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const unregisterClearCallbackRef = useRef( useTerminalCallbacksStore.getState().unregisterClearCallback, ); + const registerScrollToBottomCallbackRef = useRef( + useTerminalCallbacksStore.getState().registerScrollToBottomCallback, + ); + const unregisterScrollToBottomCallbackRef = useRef( + useTerminalCallbacksStore.getState().unregisterScrollToBottomCallback, + ); const parentTabIdRef = useRef(parentTabId); parentTabIdRef.current = parentTabId; @@ -275,6 +281,15 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { [isFocused], ); + useAppHotkey( + "SCROLL_TO_BOTTOM", + () => { + xtermRef.current?.scrollToBottom(); + }, + { enabled: isFocused, preventDefault: true }, + [isFocused], + ); + useEffect(() => { const container = terminalRef.current; if (!container) return; @@ -462,6 +477,10 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { clearScrollbackRef.current({ paneId }); }; + const handleScrollToBottom = () => { + xterm.scrollToBottom(); + }; + const handleWrite = (data: string) => { if (!isExitedRef.current) { writeRef.current({ paneId, data }); @@ -481,6 +500,9 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { // Register clear callback for context menu access registerClearCallbackRef.current(paneId, handleClear); + // Register scroll to bottom callback for context menu access + registerScrollToBottomCallbackRef.current(paneId, handleScrollToBottom); + const cleanupFocus = setupFocusListener(xterm, () => handleTerminalFocusRef.current(), ); @@ -510,6 +532,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { cleanupPaste(); cleanupQuerySuppression(); unregisterClearCallbackRef.current(paneId); + unregisterScrollToBottomCallbackRef.current(paneId); debouncedSetTabAutoTitleRef.current?.cancel?.(); // Detach instead of kill to keep PTY running for reattachment detachRef.current({ paneId }); diff --git a/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts b/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts index eb69d7cb78e..e54a460bf83 100644 --- a/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts +++ b/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts @@ -2,14 +2,22 @@ import { create } from "zustand"; interface TerminalCallbacksState { clearCallbacks: Map void>; + scrollToBottomCallbacks: Map void>; registerClearCallback: (paneId: string, callback: () => void) => void; unregisterClearCallback: (paneId: string) => void; getClearCallback: (paneId: string) => (() => void) | undefined; + registerScrollToBottomCallback: ( + paneId: string, + callback: () => void, + ) => void; + unregisterScrollToBottomCallback: (paneId: string) => void; + getScrollToBottomCallback: (paneId: string) => (() => void) | undefined; } export const useTerminalCallbacksStore = create()( (set, get) => ({ clearCallbacks: new Map(), + scrollToBottomCallbacks: new Map(), registerClearCallback: (paneId, callback) => { set((state) => { @@ -30,5 +38,25 @@ export const useTerminalCallbacksStore = create()( getClearCallback: (paneId) => { return get().clearCallbacks.get(paneId); }, + + registerScrollToBottomCallback: (paneId, callback) => { + set((state) => { + const newCallbacks = new Map(state.scrollToBottomCallbacks); + newCallbacks.set(paneId, callback); + return { scrollToBottomCallbacks: newCallbacks }; + }); + }, + + unregisterScrollToBottomCallback: (paneId) => { + set((state) => { + const newCallbacks = new Map(state.scrollToBottomCallbacks); + newCallbacks.delete(paneId); + return { scrollToBottomCallbacks: newCallbacks }; + }); + }, + + getScrollToBottomCallback: (paneId) => { + return get().scrollToBottomCallbacks.get(paneId); + }, }), ); diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index c889f1127fc..a8133df6018 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -478,6 +478,12 @@ export const HOTKEYS = { label: "Clear Terminal", category: "Terminal", }), + SCROLL_TO_BOTTOM: defineHotkey({ + keys: "meta+shift+down", + label: "Scroll to Bottom", + category: "Terminal", + description: "Scroll the active terminal to the bottom", + }), PREV_TERMINAL: defineHotkey({ keys: "meta+left", label: "Previous Terminal",