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.
This commit is contained in:
SuperComboGamer
2025-12-14 13:48:26 -05:00
parent 589155fa1c
commit 480589510e
3 changed files with 122 additions and 42 deletions

View File

@@ -305,6 +305,15 @@ export function TerminalView() {
// Create terminal in new tab // Create terminal in new tab
const createTerminalInNewTab = async () => { 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(); const tabId = addTerminalTab();
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -332,6 +341,8 @@ export function TerminalView() {
} }
} catch (err) { } catch (err) {
console.error("[Terminal] Create session error:", err); console.error("[Terminal] Create session error:", err);
} finally {
isCreatingRef.current = false;
} }
}; };
@@ -465,7 +476,7 @@ export function TerminalView() {
} }
/> />
)} )}
<Panel defaultSize={panelSize} minSize={15}> <Panel defaultSize={panelSize} minSize={25}>
{renderPanelContent(panel)} {renderPanelContent(panel)}
</Panel> </Panel>
</React.Fragment> </React.Fragment>

View File

@@ -59,6 +59,8 @@ export function TerminalPanel({
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastShortcutTimeRef = useRef<number>(0); const lastShortcutTimeRef = useRef<number>(0);
const resizeDebounceRef = useRef<NodeJS.Timeout | null>(null);
const focusHandlerRef = useRef<{ dispose: () => void } | null>(null);
const [isTerminalReady, setIsTerminalReady] = useState(false); const [isTerminalReady, setIsTerminalReady] = useState(false);
const [shellName, setShellName] = useState("shell"); const [shellName, setShellName] = useState("shell");
@@ -167,17 +169,28 @@ export function TerminalPanel({
console.warn("[Terminal] WebGL addon not available, falling back to canvas"); console.warn("[Terminal] WebGL addon not available, falling back to canvas");
} }
// Fit terminal to container // Fit terminal to container using requestAnimationFrame for better timing
setTimeout(() => { requestAnimationFrame(() => {
fitAddon.fit(); if (fitAddon && terminalRef.current) {
}, 0); 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; xtermRef.current = terminal;
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
setIsTerminalReady(true); setIsTerminalReady(true);
// Handle focus - use ref to avoid re-running effect // Handle focus - use ref to avoid re-running effect
terminal.onData(() => { // Store disposer to prevent memory leak
focusHandlerRef.current = terminal.onData(() => {
onFocusRef.current(); onFocusRef.current();
}); });
@@ -236,6 +249,19 @@ export function TerminalPanel({
// Cleanup // Cleanup
return () => { return () => {
mounted = false; 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) { if (xtermRef.current) {
xtermRef.current.dispose(); xtermRef.current.dispose();
xtermRef.current = null; xtermRef.current = null;
@@ -273,6 +299,8 @@ export function TerminalPanel({
terminal.write(msg.data); terminal.write(msg.data);
break; break;
case "scrollback": case "scrollback":
// Clear terminal before replaying scrollback to prevent duplicates on reconnection
terminal.clear();
// Replay scrollback buffer (previous terminal output) // Replay scrollback buffer (previous terminal output)
if (msg.data) { if (msg.data) {
terminal.write(msg.data); terminal.write(msg.data);
@@ -343,17 +371,40 @@ export function TerminalPanel({
}; };
}, [sessionId, authToken, wsUrl, isTerminalReady]); }, [sessionId, authToken, wsUrl, isTerminalReady]);
// Handle resize // Handle resize with debouncing and validation
const handleResize = useCallback(() => { const handleResize = useCallback(() => {
if (fitAddonRef.current && xtermRef.current) { // Clear any pending resize
fitAddonRef.current.fit(); if (resizeDebounceRef.current) {
const { cols, rows } = xtermRef.current; clearTimeout(resizeDebounceRef.current);
// Send resize to server
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
}
} }
// 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 // Resize observer
@@ -376,12 +427,12 @@ export function TerminalPanel({
}; };
}, [handleResize]); }, [handleResize]);
// Focus terminal when becoming active // Focus terminal when becoming active or when terminal becomes ready
useEffect(() => { useEffect(() => {
if (isActive && xtermRef.current) { if (isActive && isTerminalReady && xtermRef.current) {
xtermRef.current.focus(); xtermRef.current.focus();
} }
}, [isActive]); }, [isActive, isTerminalReady]);
// Update terminal font size when it changes // Update terminal font size when it changes
useEffect(() => { useEffect(() => {

View File

@@ -197,6 +197,8 @@ wss.on("connection", (ws: WebSocket) => {
// Track WebSocket connections per session // Track WebSocket connections per session
const terminalConnections: Map<string, Set<WebSocket>> = new Map(); const terminalConnections: Map<string, Set<WebSocket>> = new Map();
// Track last resize dimensions per session to deduplicate resize messages
const lastResizeDimensions: Map<string, { cols: number; rows: number }> = new Map();
// Terminal WebSocket connection handler // Terminal WebSocket connection handler
terminalWss.on( terminalWss.on(
@@ -248,7 +250,29 @@ terminalWss.on(
} }
terminalConnections.get(sessionId)!.add(ws); 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) => { const unsubscribeData = terminalService.onData((sid, data) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) { if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "data", data })); ws.send(JSON.stringify({ type: "data", data }));
@@ -275,9 +299,22 @@ terminalWss.on(
break; break;
case "resize": case "resize":
// Resize terminal // Resize terminal with deduplication
if (msg.cols && msg.rows) { 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; break;
@@ -307,6 +344,8 @@ terminalWss.on(
connections.delete(ws); connections.delete(ws);
if (connections.size === 0) { if (connections.size === 0) {
terminalConnections.delete(sessionId); terminalConnections.delete(sessionId);
// Clean up resize dimensions tracking when session has no more connections
lastResizeDimensions.delete(sessionId);
} }
} }
}); });
@@ -316,27 +355,6 @@ terminalWss.on(
unsubscribeData(); unsubscribeData();
unsubscribeExit(); 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,
})
);
}
} }
); );