Skip to content

Commit 7ddd9f8

Browse files
feat: enhance terminal navigation and session management
- Implemented spatial navigation between terminal panes using directional shortcuts (Ctrl+Alt+Arrow keys). - Improved session handling by ensuring stale sessions are automatically removed when the server indicates they are invalid. - Added customizable keyboard shortcuts for terminal actions and enhanced search functionality with dedicated highlighting colors. - Updated terminal themes to include search highlighting colors for better visibility during searches. - Refactored terminal layout saving logic to prevent incomplete state saves during project restoration.
1 parent f504a00 commit 7ddd9f8

6 files changed

Lines changed: 536 additions & 64 deletions

File tree

apps/ui/src/components/views/terminal-view.tsx

Lines changed: 132 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,8 @@ export function TerminalView() {
447447
// The path check in restoreLayout will handle this
448448

449449
// Save layout for previous project (if there was one and has terminals)
450-
if (prevPath && terminalState.tabs.length > 0) {
450+
// BUT don't save if we were mid-restore for that project (would save incomplete state)
451+
if (prevPath && terminalState.tabs.length > 0 && restoringProjectPathRef.current !== prevPath) {
451452
saveTerminalLayout(prevPath);
452453
}
453454

@@ -460,19 +461,25 @@ export function TerminalView() {
460461
return;
461462
}
462463

464+
// ALWAYS clear existing terminals when switching projects
465+
// This is critical - prevents old project's terminals from "bleeding" into new project
466+
clearTerminalState();
467+
463468
// Check for saved layout for this project
464469
const savedLayout = getPersistedTerminalLayout(currentPath);
465470

466-
if (savedLayout && savedLayout.tabs.length > 0) {
467-
// Restore the saved layout - try to reconnect to existing sessions
468-
// Track which project we're restoring to detect stale restores
469-
restoringProjectPathRef.current = currentPath;
471+
// If no saved layout or no tabs, we're done - terminal starts fresh for this project
472+
if (!savedLayout || savedLayout.tabs.length === 0) {
473+
console.log("[Terminal] No saved layout for project, starting fresh");
474+
return;
475+
}
470476

471-
// Clear existing terminals first (only client state, sessions stay on server)
472-
clearTerminalState();
477+
// Restore the saved layout - try to reconnect to existing sessions
478+
// Track which project we're restoring to detect stale restores
479+
restoringProjectPathRef.current = currentPath;
473480

474-
// Create terminals and build layout - try to reconnect or create new
475-
const restoreLayout = async () => {
481+
// Create terminals and build layout - try to reconnect or create new
482+
const restoreLayout = async () => {
476483
// Check if we're still restoring the same project (user may have switched)
477484
if (restoringProjectPathRef.current !== currentPath) {
478485
console.log("[Terminal] Restore cancelled - project changed");
@@ -643,21 +650,29 @@ export function TerminalView() {
643650
};
644651

645652
restoreLayout();
646-
}
647653
}, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]);
648654

649655
// Save terminal layout whenever it changes (debounced to prevent excessive writes)
650656
// Also save when tabs become empty so closed terminals stay closed on refresh
651657
const saveLayoutTimeoutRef = useRef<NodeJS.Timeout | null>(null);
658+
const pendingSavePathRef = useRef<string | null>(null);
652659
useEffect(() => {
660+
const projectPath = currentProject?.path;
653661
// Don't save while restoring this project's layout
654-
if (currentProject?.path && restoringProjectPathRef.current !== currentProject.path) {
662+
if (projectPath && restoringProjectPathRef.current !== projectPath) {
655663
// Debounce saves to prevent excessive localStorage writes during rapid changes
656664
if (saveLayoutTimeoutRef.current) {
657665
clearTimeout(saveLayoutTimeoutRef.current);
658666
}
667+
// Capture the project path at schedule time so we save to the correct project
668+
// even if user switches projects before the timeout fires
669+
pendingSavePathRef.current = projectPath;
659670
saveLayoutTimeoutRef.current = setTimeout(() => {
660-
saveTerminalLayout(currentProject.path);
671+
// Only save if we're still on the same project
672+
if (pendingSavePathRef.current === projectPath) {
673+
saveTerminalLayout(projectPath);
674+
}
675+
pendingSavePathRef.current = null;
661676
saveLayoutTimeoutRef.current = null;
662677
}, 500); // 500ms debounce
663678
}
@@ -949,41 +964,112 @@ export function TerminalView() {
949964
});
950965
}, []);
951966

952-
// Navigate between terminal panes with Ctrl+Alt+Arrow keys
953-
const navigateToTerminal = useCallback((direction: "next" | "prev") => {
967+
// Navigate between terminal panes with directional awareness
968+
// Arrow keys navigate in the actual spatial direction within the layout
969+
const navigateToTerminal = useCallback((direction: "up" | "down" | "left" | "right") => {
954970
if (!activeTab?.layout) return;
955971

956-
const terminalIds = getTerminalIds(activeTab.layout);
957-
if (terminalIds.length <= 1) return;
958-
959-
const currentIndex = terminalIds.indexOf(terminalState.activeSessionId || "");
960-
if (currentIndex === -1) {
972+
const currentSessionId = terminalState.activeSessionId;
973+
if (!currentSessionId) {
961974
// If no terminal is active, focus the first one
962-
setActiveTerminalSession(terminalIds[0]);
975+
const terminalIds = getTerminalIds(activeTab.layout);
976+
if (terminalIds.length > 0) {
977+
setActiveTerminalSession(terminalIds[0]);
978+
}
963979
return;
964980
}
965981

966-
let newIndex: number;
967-
if (direction === "next") {
968-
newIndex = (currentIndex + 1) % terminalIds.length;
969-
} else {
970-
newIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length;
971-
}
982+
// Find the terminal in the given direction
983+
// The algorithm traverses the layout tree to find spatially adjacent terminals
984+
const findTerminalInDirection = (
985+
layout: TerminalPanelContent,
986+
targetId: string,
987+
dir: "up" | "down" | "left" | "right"
988+
): string | null => {
989+
// Helper to get all terminal IDs from a layout subtree
990+
const getAllTerminals = (node: TerminalPanelContent): string[] => {
991+
if (node.type === "terminal") return [node.sessionId];
992+
return node.panels.flatMap(getAllTerminals);
993+
};
972994

973-
setActiveTerminalSession(terminalIds[newIndex]);
995+
// Helper to find terminal and its path in the tree
996+
type PathEntry = { node: TerminalPanelContent; index: number; direction: "horizontal" | "vertical" };
997+
const findPath = (
998+
node: TerminalPanelContent,
999+
target: string,
1000+
path: PathEntry[] = []
1001+
): PathEntry[] | null => {
1002+
if (node.type === "terminal") {
1003+
return node.sessionId === target ? path : null;
1004+
}
1005+
for (let i = 0; i < node.panels.length; i++) {
1006+
const result = findPath(node.panels[i], target, [
1007+
...path,
1008+
{ node, index: i, direction: node.direction },
1009+
]);
1010+
if (result) return result;
1011+
}
1012+
return null;
1013+
};
1014+
1015+
const path = findPath(layout, targetId);
1016+
if (!path || path.length === 0) return null;
1017+
1018+
// Determine which split direction we need based on arrow direction
1019+
// left/right navigation works in "horizontal" splits (panels side by side)
1020+
// up/down navigation works in "vertical" splits (panels stacked)
1021+
const neededDirection = dir === "left" || dir === "right" ? "horizontal" : "vertical";
1022+
const goingForward = dir === "right" || dir === "down";
1023+
1024+
// Walk up the path to find a split in the right direction with an adjacent panel
1025+
for (let i = path.length - 1; i >= 0; i--) {
1026+
const entry = path[i];
1027+
if (entry.direction === neededDirection) {
1028+
const siblings = entry.node.type === "split" ? entry.node.panels : [];
1029+
const nextIndex = goingForward ? entry.index + 1 : entry.index - 1;
1030+
1031+
if (nextIndex >= 0 && nextIndex < siblings.length) {
1032+
// Found an adjacent panel in the right direction
1033+
const adjacentPanel = siblings[nextIndex];
1034+
const adjacentTerminals = getAllTerminals(adjacentPanel);
1035+
1036+
if (adjacentTerminals.length > 0) {
1037+
// When moving forward (right/down), pick the first terminal in that subtree
1038+
// When moving backward (left/up), pick the last terminal in that subtree
1039+
return goingForward
1040+
? adjacentTerminals[0]
1041+
: adjacentTerminals[adjacentTerminals.length - 1];
1042+
}
1043+
}
1044+
}
1045+
}
1046+
1047+
return null;
1048+
};
1049+
1050+
const nextTerminal = findTerminalInDirection(activeTab.layout, currentSessionId, direction);
1051+
if (nextTerminal) {
1052+
setActiveTerminalSession(nextTerminal);
1053+
}
9741054
}, [activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]);
9751055

9761056
// Handle global keyboard shortcuts for pane navigation
9771057
useEffect(() => {
9781058
const handleKeyDown = (e: KeyboardEvent) => {
9791059
// Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation
9801060
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) {
981-
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
1061+
if (e.key === "ArrowRight") {
1062+
e.preventDefault();
1063+
navigateToTerminal("right");
1064+
} else if (e.key === "ArrowLeft") {
1065+
e.preventDefault();
1066+
navigateToTerminal("left");
1067+
} else if (e.key === "ArrowDown") {
9821068
e.preventDefault();
983-
navigateToTerminal("next");
984-
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
1069+
navigateToTerminal("down");
1070+
} else if (e.key === "ArrowUp") {
9851071
e.preventDefault();
986-
navigateToTerminal("prev");
1072+
navigateToTerminal("up");
9871073
}
9881074
}
9891075
};
@@ -1019,6 +1105,16 @@ export function TerminalView() {
10191105
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
10201106
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
10211107
onNewTab={createTerminalInNewTab}
1108+
onNavigateUp={() => navigateToTerminal("up")}
1109+
onNavigateDown={() => navigateToTerminal("down")}
1110+
onNavigateLeft={() => navigateToTerminal("left")}
1111+
onNavigateRight={() => navigateToTerminal("right")}
1112+
onSessionInvalid={() => {
1113+
// Auto-remove stale session when server says it doesn't exist
1114+
// This handles cases like server restart where sessions are lost
1115+
console.log(`[Terminal] Session ${content.sessionId} is invalid, removing from layout`);
1116+
killTerminal(content.sessionId);
1117+
}}
10221118
isDragging={activeDragId === content.sessionId}
10231119
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
10241120
fontSize={terminalFontSize}
@@ -1384,6 +1480,11 @@ export function TerminalView() {
13841480
onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)}
13851481
onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)}
13861482
onNewTab={createTerminalInNewTab}
1483+
onSessionInvalid={() => {
1484+
const sessionId = terminalState.maximizedSessionId!;
1485+
console.log(`[Terminal] Maximized session ${sessionId} is invalid, removing from layout`);
1486+
killTerminal(sessionId);
1487+
}}
13871488
isDragging={false}
13881489
isDropTarget={false}
13891490
fontSize={findTerminalFontSize(terminalState.maximizedSessionId)}

0 commit comments

Comments
 (0)