From 6f82f641959c51cfea70d1f43784f67ae097ec2a Mon Sep 17 00:00:00 2001 From: Alec Koifman Date: Thu, 18 Dec 2025 16:23:05 -0500 Subject: [PATCH 1/4] feat: implement context menu for terminal actions - Added context menu with options to copy, paste, select all, and clear terminal content. - Integrated keyboard shortcuts for copy (Ctrl/Cmd+C), paste (Ctrl/Cmd+V), and select all (Ctrl/Cmd+A). - Enhanced platform detection for Mac users to adjust key bindings accordingly. - Implemented functionality to handle context menu actions and close the menu on outside clicks or key events. --- .../views/terminal-view/terminal-panel.tsx | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/apps/app/src/components/views/terminal-view/terminal-panel.tsx b/apps/app/src/components/views/terminal-view/terminal-panel.tsx index 3cfd967b..affaa65c 100644 --- a/apps/app/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -9,6 +9,10 @@ import { Terminal, ZoomIn, ZoomOut, + Copy, + ClipboardPaste, + CheckSquare, + Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -66,6 +70,13 @@ export function TerminalPanel({ const focusHandlerRef = useRef<{ dispose: () => void } | null>(null); const [isTerminalReady, setIsTerminalReady] = useState(false); const [shellName, setShellName] = useState("shell"); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [isMac, setIsMac] = useState(false); + + // Detect platform on mount + useEffect(() => { + setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0); + }, []); // Get effective theme from store const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); @@ -84,6 +95,8 @@ export function TerminalPanel({ fontSizeRef.current = fontSize; const themeRef = useRef(effectiveTheme); themeRef.current = effectiveTheme; + const copySelectionRef = useRef<() => Promise>(() => Promise.resolve(false)); + const pasteFromClipboardRef = useRef<() => Promise>(() => Promise.resolve()); // Zoom functions - use the prop callback const zoomIn = useCallback(() => { @@ -98,6 +111,75 @@ export function TerminalPanel({ onFontSizeChange(DEFAULT_FONT_SIZE); }, [onFontSizeChange]); + // Copy selected text to clipboard + const copySelection = useCallback(async (): Promise => { + const terminal = xtermRef.current; + if (!terminal) return false; + + const selection = terminal.getSelection(); + if (!selection) return false; + + try { + await navigator.clipboard.writeText(selection); + return true; + } catch (err) { + console.error("[Terminal] Copy failed:", err); + return false; + } + }, []); + copySelectionRef.current = copySelection; + + // Paste from clipboard + const pasteFromClipboard = useCallback(async () => { + const terminal = xtermRef.current; + if (!terminal || !wsRef.current) return; + + try { + const text = await navigator.clipboard.readText(); + if (text && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "input", data: text })); + } + } catch (err) { + console.error("[Terminal] Paste failed:", err); + } + }, []); + pasteFromClipboardRef.current = pasteFromClipboard; + + // Select all terminal content + const selectAll = useCallback(() => { + xtermRef.current?.selectAll(); + }, []); + + // Clear terminal + const clearTerminal = useCallback(() => { + xtermRef.current?.clear(); + }, []); + + // Close context menu + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + // Handle context menu action + const handleContextMenuAction = useCallback(async (action: "copy" | "paste" | "selectAll" | "clear") => { + closeContextMenu(); + switch (action) { + case "copy": + await copySelection(); + break; + case "paste": + await pasteFromClipboard(); + break; + case "selectAll": + selectAll(); + break; + case "clear": + clearTerminal(); + break; + } + xtermRef.current?.focus(); + }, [closeContextMenu, copySelection, pasteFromClipboard, selectAll, clearTerminal]); + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; const wsUrl = serverUrl.replace(/^http/, "ws"); @@ -263,6 +345,44 @@ export function TerminalPanel({ return false; } + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const modKey = isMac ? event.metaKey : event.ctrlKey; + const otherModKey = isMac ? event.ctrlKey : event.metaKey; + + // Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention) + if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') { + event.preventDefault(); + copySelectionRef.current(); + return false; + } + + // Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT + if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyC') { + const hasSelection = terminal.hasSelection(); + if (hasSelection) { + event.preventDefault(); + copySelectionRef.current(); + terminal.clearSelection(); + return false; + } + // No selection - let xterm handle it (sends SIGINT) + return true; + } + + // Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste + if (modKey && !otherModKey && !event.altKey && code === 'KeyV') { + event.preventDefault(); + pasteFromClipboardRef.current(); + return false; + } + + // Ctrl+A / Cmd+A - Select all + if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyA') { + event.preventDefault(); + terminal.selectAll(); + return false; + } + // Let xterm handle all other keys return true; }); @@ -548,6 +668,34 @@ export function TerminalPanel({ return () => container.removeEventListener("wheel", handleWheel); }, [zoomIn, zoomOut]); + // Close context menu on click outside or scroll + useEffect(() => { + if (!contextMenu) return; + + const handleClick = () => closeContextMenu(); + const handleScroll = () => closeContextMenu(); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") closeContextMenu(); + }; + + document.addEventListener("click", handleClick); + document.addEventListener("scroll", handleScroll, true); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("click", handleClick); + document.removeEventListener("scroll", handleScroll, true); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [contextMenu, closeContextMenu]); + + // Handle right-click context menu + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + // Combine refs for the container const setRefs = useCallback((node: HTMLDivElement | null) => { containerRef.current = node; @@ -695,7 +843,50 @@ export function TerminalPanel({ ref={terminalRef} className="flex-1 overflow-hidden" style={{ backgroundColor: currentTerminalTheme.background }} + onContextMenu={handleContextMenu} /> + + {/* Context menu */} + {contextMenu && ( +
e.stopPropagation()} + > + + +
+ + +
+ )}
); } From 15ae1fe147fd914bc598617aa924bcbadec349ea Mon Sep 17 00:00:00 2001 From: Alec Koifman Date: Thu, 18 Dec 2025 16:54:19 -0500 Subject: [PATCH 2/4] feat: enhance terminal context menu with keyboard navigation - Improved context menu functionality by adding keyboard navigation support for actions (copy, paste, select all, clear). - Utilized refs to manage focus on menu items and updated platform detection for Mac users. - Ensured context menu closes on outside clicks and handles keyboard events effectively. --- .../views/terminal-view/terminal-panel.tsx | 94 ++++++++++++++++--- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/apps/app/src/components/views/terminal-view/terminal-panel.tsx b/apps/app/src/components/views/terminal-view/terminal-panel.tsx index affaa65c..333153d4 100644 --- a/apps/app/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -72,10 +72,19 @@ export function TerminalPanel({ const [shellName, setShellName] = useState("shell"); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [isMac, setIsMac] = useState(false); + const isMacRef = useRef(false); + const contextMenuRef = useRef(null); + const [focusedMenuIndex, setFocusedMenuIndex] = useState(0); // Detect platform on mount useEffect(() => { - setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0); + // Use modern userAgentData API with fallback to deprecated navigator.platform + const detected = (navigator as Navigator & { userAgentData?: { platform: string } }) + .userAgentData?.platform?.toLowerCase().includes("mac") + ?? navigator.platform?.toLowerCase().includes("mac") + ?? false; + setIsMac(detected); + isMacRef.current = detected; }, []); // Get effective theme from store @@ -345,9 +354,8 @@ export function TerminalPanel({ return false; } - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - const modKey = isMac ? event.metaKey : event.ctrlKey; - const otherModKey = isMac ? event.ctrlKey : event.metaKey; + const modKey = isMacRef.current ? event.metaKey : event.ctrlKey; + const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey; // Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention) if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') { @@ -668,14 +676,46 @@ export function TerminalPanel({ return () => container.removeEventListener("wheel", handleWheel); }, [zoomIn, zoomOut]); - // Close context menu on click outside or scroll + // Context menu actions for keyboard navigation + const menuActions = ["copy", "paste", "selectAll", "clear"] as const; + + // Close context menu on click outside or scroll, handle keyboard navigation useEffect(() => { if (!contextMenu) return; + // Reset focus index and focus menu when opened + setFocusedMenuIndex(0); + requestAnimationFrame(() => { + const firstButton = contextMenuRef.current?.querySelector('[role="menuitem"]'); + firstButton?.focus(); + }); + const handleClick = () => closeContextMenu(); const handleScroll = () => closeContextMenu(); const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") closeContextMenu(); + switch (e.key) { + case "Escape": + e.preventDefault(); + closeContextMenu(); + break; + case "ArrowDown": + e.preventDefault(); + setFocusedMenuIndex((prev) => (prev + 1) % menuActions.length); + break; + case "ArrowUp": + e.preventDefault(); + setFocusedMenuIndex((prev) => (prev - 1 + menuActions.length) % menuActions.length); + break; + case "Enter": + case " ": + e.preventDefault(); + handleContextMenuAction(menuActions[focusedMenuIndex]); + break; + case "Tab": + e.preventDefault(); + closeContextMenu(); + break; + } }; document.addEventListener("click", handleClick); @@ -687,7 +727,14 @@ export function TerminalPanel({ document.removeEventListener("scroll", handleScroll, true); document.removeEventListener("keydown", handleKeyDown); }; - }, [contextMenu, closeContextMenu]); + }, [contextMenu, closeContextMenu, focusedMenuIndex, handleContextMenuAction]); + + // Focus the correct menu item when navigation changes + useEffect(() => { + if (!contextMenu || !contextMenuRef.current) return; + const buttons = contextMenuRef.current.querySelectorAll('[role="menuitem"]'); + buttons[focusedMenuIndex]?.focus(); + }, [focusedMenuIndex, contextMenu]); // Handle right-click context menu const handleContextMenu = useCallback((e: React.MouseEvent) => { @@ -849,12 +896,20 @@ export function TerminalPanel({ {/* Context menu */} {contextMenu && (
e.stopPropagation()} > -
+