mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user