From 480589510e535a7225f3a29b813d2bcaab48a319 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sun, 14 Dec 2025 13:48:26 -0500 Subject: [PATCH] feat: enhance terminal functionality with debouncing and resize validation - Implemented debouncing for terminal tab creation to prevent rapid requests. - Improved terminal resizing logic with validation for minimum dimensions and deduplication of resize messages. - Updated terminal panel to handle focus and cleanup more efficiently, preventing memory leaks. - Enhanced initial connection handling to ensure scrollback data is sent before subscribing to terminal data. --- .../src/components/views/terminal-view.tsx | 13 ++- .../views/terminal-view/terminal-panel.tsx | 85 +++++++++++++++---- apps/server/src/index.ts | 66 ++++++++------ 3 files changed, 122 insertions(+), 42 deletions(-) diff --git a/apps/app/src/components/views/terminal-view.tsx b/apps/app/src/components/views/terminal-view.tsx index 27d548c2..25fab530 100644 --- a/apps/app/src/components/views/terminal-view.tsx +++ b/apps/app/src/components/views/terminal-view.tsx @@ -305,6 +305,15 @@ export function TerminalView() { // Create terminal in new tab const createTerminalInNewTab = async () => { + // Debounce: prevent rapid terminal creation + const now = Date.now(); + if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) { + console.log("[Terminal] Debounced terminal tab creation"); + return; + } + lastCreateTimeRef.current = now; + isCreatingRef.current = true; + const tabId = addTerminalTab(); try { const headers: Record = { @@ -332,6 +341,8 @@ export function TerminalView() { } } catch (err) { console.error("[Terminal] Create session error:", err); + } finally { + isCreatingRef.current = false; } }; @@ -465,7 +476,7 @@ export function TerminalView() { } /> )} - + {renderPanelContent(panel)} 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 fee8ecf3..6fe487b6 100644 --- a/apps/app/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/app/src/components/views/terminal-view/terminal-panel.tsx @@ -59,6 +59,8 @@ export function TerminalPanel({ const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); const lastShortcutTimeRef = useRef(0); + const resizeDebounceRef = useRef(null); + const focusHandlerRef = useRef<{ dispose: () => void } | null>(null); const [isTerminalReady, setIsTerminalReady] = useState(false); const [shellName, setShellName] = useState("shell"); @@ -167,17 +169,28 @@ export function TerminalPanel({ console.warn("[Terminal] WebGL addon not available, falling back to canvas"); } - // Fit terminal to container - setTimeout(() => { - fitAddon.fit(); - }, 0); + // Fit terminal to container using requestAnimationFrame for better timing + requestAnimationFrame(() => { + if (fitAddon && terminalRef.current) { + const rect = terminalRef.current.getBoundingClientRect(); + // Only fit if container has valid dimensions + if (rect.width >= 100 && rect.height >= 50) { + try { + fitAddon.fit(); + } catch (err) { + console.error("[Terminal] Initial fit error:", err); + } + } + } + }); xtermRef.current = terminal; fitAddonRef.current = fitAddon; setIsTerminalReady(true); // Handle focus - use ref to avoid re-running effect - terminal.onData(() => { + // Store disposer to prevent memory leak + focusHandlerRef.current = terminal.onData(() => { onFocusRef.current(); }); @@ -236,6 +249,19 @@ export function TerminalPanel({ // Cleanup return () => { mounted = false; + + // Dispose focus handler to prevent memory leak + if (focusHandlerRef.current) { + focusHandlerRef.current.dispose(); + focusHandlerRef.current = null; + } + + // Clear resize debounce timer + if (resizeDebounceRef.current) { + clearTimeout(resizeDebounceRef.current); + resizeDebounceRef.current = null; + } + if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; @@ -273,6 +299,8 @@ export function TerminalPanel({ terminal.write(msg.data); break; case "scrollback": + // Clear terminal before replaying scrollback to prevent duplicates on reconnection + terminal.clear(); // Replay scrollback buffer (previous terminal output) if (msg.data) { terminal.write(msg.data); @@ -343,17 +371,40 @@ export function TerminalPanel({ }; }, [sessionId, authToken, wsUrl, isTerminalReady]); - // Handle resize + // Handle resize with debouncing and validation const handleResize = useCallback(() => { - if (fitAddonRef.current && xtermRef.current) { - fitAddonRef.current.fit(); - const { cols, rows } = xtermRef.current; - - // Send resize to server - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); - } + // Clear any pending resize + if (resizeDebounceRef.current) { + clearTimeout(resizeDebounceRef.current); } + + // Debounce resize operations to prevent race conditions + resizeDebounceRef.current = setTimeout(() => { + if (!fitAddonRef.current || !xtermRef.current || !terminalRef.current) return; + + // Validate minimum dimensions before resizing + const container = terminalRef.current; + const rect = container.getBoundingClientRect(); + const MIN_WIDTH = 100; + const MIN_HEIGHT = 50; + + if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) { + console.log("[Terminal] Container too small to resize:", rect.width, "x", rect.height); + return; + } + + try { + fitAddonRef.current.fit(); + const { cols, rows } = xtermRef.current; + + // Send resize to server + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); + } + } catch (err) { + console.error("[Terminal] Resize error:", err); + } + }, 100); // 100ms debounce }, []); // Resize observer @@ -376,12 +427,12 @@ export function TerminalPanel({ }; }, [handleResize]); - // Focus terminal when becoming active + // Focus terminal when becoming active or when terminal becomes ready useEffect(() => { - if (isActive && xtermRef.current) { + if (isActive && isTerminalReady && xtermRef.current) { xtermRef.current.focus(); } - }, [isActive]); + }, [isActive, isTerminalReady]); // Update terminal font size when it changes useEffect(() => { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b89469b3..551c3c3d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -197,6 +197,8 @@ wss.on("connection", (ws: WebSocket) => { // Track WebSocket connections per session const terminalConnections: Map> = new Map(); +// Track last resize dimensions per session to deduplicate resize messages +const lastResizeDimensions: Map = new Map(); // Terminal WebSocket connection handler terminalWss.on( @@ -248,7 +250,29 @@ terminalWss.on( } terminalConnections.get(sessionId)!.add(ws); - // Subscribe to terminal data + // Send initial connection success FIRST + ws.send( + JSON.stringify({ + type: "connected", + sessionId, + shell: session.shell, + cwd: session.cwd, + }) + ); + + // Send scrollback buffer BEFORE subscribing to prevent race condition + // This ensures data isn't sent twice (once in scrollback, once via subscription) + const scrollback = terminalService.getScrollback(sessionId); + if (scrollback && scrollback.length > 0) { + ws.send( + JSON.stringify({ + type: "scrollback", + data: scrollback, + }) + ); + } + + // NOW subscribe to terminal data (after scrollback is sent) const unsubscribeData = terminalService.onData((sid, data) => { if (sid === sessionId && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "data", data })); @@ -275,9 +299,22 @@ terminalWss.on( break; case "resize": - // Resize terminal + // Resize terminal with deduplication if (msg.cols && msg.rows) { - terminalService.resize(sessionId, msg.cols, msg.rows); + // Check if dimensions are different from last resize + const lastDimensions = lastResizeDimensions.get(sessionId); + if ( + !lastDimensions || + lastDimensions.cols !== msg.cols || + lastDimensions.rows !== msg.rows + ) { + // Only resize if dimensions changed + terminalService.resize(sessionId, msg.cols, msg.rows); + lastResizeDimensions.set(sessionId, { + cols: msg.cols, + rows: msg.rows, + }); + } } break; @@ -307,6 +344,8 @@ terminalWss.on( connections.delete(ws); if (connections.size === 0) { terminalConnections.delete(sessionId); + // Clean up resize dimensions tracking when session has no more connections + lastResizeDimensions.delete(sessionId); } } }); @@ -316,27 +355,6 @@ terminalWss.on( unsubscribeData(); unsubscribeExit(); }); - - // Send initial connection success - ws.send( - JSON.stringify({ - type: "connected", - sessionId, - shell: session.shell, - cwd: session.cwd, - }) - ); - - // Send scrollback buffer to replay previous output - const scrollback = terminalService.getScrollback(sessionId); - if (scrollback && scrollback.length > 0) { - ws.send( - JSON.stringify({ - type: "scrollback", - data: scrollback, - }) - ); - } } );