mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
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.
This commit is contained in:
@@ -447,7 +447,8 @@ export function TerminalView() {
|
|||||||
// The path check in restoreLayout will handle this
|
// The path check in restoreLayout will handle this
|
||||||
|
|
||||||
// Save layout for previous project (if there was one and has terminals)
|
// Save layout for previous project (if there was one and has terminals)
|
||||||
if (prevPath && terminalState.tabs.length > 0) {
|
// BUT don't save if we were mid-restore for that project (would save incomplete state)
|
||||||
|
if (prevPath && terminalState.tabs.length > 0 && restoringProjectPathRef.current !== prevPath) {
|
||||||
saveTerminalLayout(prevPath);
|
saveTerminalLayout(prevPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,19 +461,25 @@ export function TerminalView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALWAYS clear existing terminals when switching projects
|
||||||
|
// This is critical - prevents old project's terminals from "bleeding" into new project
|
||||||
|
clearTerminalState();
|
||||||
|
|
||||||
// Check for saved layout for this project
|
// Check for saved layout for this project
|
||||||
const savedLayout = getPersistedTerminalLayout(currentPath);
|
const savedLayout = getPersistedTerminalLayout(currentPath);
|
||||||
|
|
||||||
if (savedLayout && savedLayout.tabs.length > 0) {
|
// If no saved layout or no tabs, we're done - terminal starts fresh for this project
|
||||||
// Restore the saved layout - try to reconnect to existing sessions
|
if (!savedLayout || savedLayout.tabs.length === 0) {
|
||||||
// Track which project we're restoring to detect stale restores
|
console.log("[Terminal] No saved layout for project, starting fresh");
|
||||||
restoringProjectPathRef.current = currentPath;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing terminals first (only client state, sessions stay on server)
|
// Restore the saved layout - try to reconnect to existing sessions
|
||||||
clearTerminalState();
|
// Track which project we're restoring to detect stale restores
|
||||||
|
restoringProjectPathRef.current = currentPath;
|
||||||
|
|
||||||
// Create terminals and build layout - try to reconnect or create new
|
// Create terminals and build layout - try to reconnect or create new
|
||||||
const restoreLayout = async () => {
|
const restoreLayout = async () => {
|
||||||
// Check if we're still restoring the same project (user may have switched)
|
// Check if we're still restoring the same project (user may have switched)
|
||||||
if (restoringProjectPathRef.current !== currentPath) {
|
if (restoringProjectPathRef.current !== currentPath) {
|
||||||
console.log("[Terminal] Restore cancelled - project changed");
|
console.log("[Terminal] Restore cancelled - project changed");
|
||||||
@@ -643,21 +650,29 @@ export function TerminalView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
restoreLayout();
|
restoreLayout();
|
||||||
}
|
|
||||||
}, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]);
|
}, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]);
|
||||||
|
|
||||||
// Save terminal layout whenever it changes (debounced to prevent excessive writes)
|
// Save terminal layout whenever it changes (debounced to prevent excessive writes)
|
||||||
// Also save when tabs become empty so closed terminals stay closed on refresh
|
// Also save when tabs become empty so closed terminals stay closed on refresh
|
||||||
const saveLayoutTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveLayoutTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const pendingSavePathRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const projectPath = currentProject?.path;
|
||||||
// Don't save while restoring this project's layout
|
// Don't save while restoring this project's layout
|
||||||
if (currentProject?.path && restoringProjectPathRef.current !== currentProject.path) {
|
if (projectPath && restoringProjectPathRef.current !== projectPath) {
|
||||||
// Debounce saves to prevent excessive localStorage writes during rapid changes
|
// Debounce saves to prevent excessive localStorage writes during rapid changes
|
||||||
if (saveLayoutTimeoutRef.current) {
|
if (saveLayoutTimeoutRef.current) {
|
||||||
clearTimeout(saveLayoutTimeoutRef.current);
|
clearTimeout(saveLayoutTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
// Capture the project path at schedule time so we save to the correct project
|
||||||
|
// even if user switches projects before the timeout fires
|
||||||
|
pendingSavePathRef.current = projectPath;
|
||||||
saveLayoutTimeoutRef.current = setTimeout(() => {
|
saveLayoutTimeoutRef.current = setTimeout(() => {
|
||||||
saveTerminalLayout(currentProject.path);
|
// Only save if we're still on the same project
|
||||||
|
if (pendingSavePathRef.current === projectPath) {
|
||||||
|
saveTerminalLayout(projectPath);
|
||||||
|
}
|
||||||
|
pendingSavePathRef.current = null;
|
||||||
saveLayoutTimeoutRef.current = null;
|
saveLayoutTimeoutRef.current = null;
|
||||||
}, 500); // 500ms debounce
|
}, 500); // 500ms debounce
|
||||||
}
|
}
|
||||||
@@ -949,28 +964,93 @@ export function TerminalView() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Navigate between terminal panes with Ctrl+Alt+Arrow keys
|
// Navigate between terminal panes with directional awareness
|
||||||
const navigateToTerminal = useCallback((direction: "next" | "prev") => {
|
// Arrow keys navigate in the actual spatial direction within the layout
|
||||||
|
const navigateToTerminal = useCallback((direction: "up" | "down" | "left" | "right") => {
|
||||||
if (!activeTab?.layout) return;
|
if (!activeTab?.layout) return;
|
||||||
|
|
||||||
const terminalIds = getTerminalIds(activeTab.layout);
|
const currentSessionId = terminalState.activeSessionId;
|
||||||
if (terminalIds.length <= 1) return;
|
if (!currentSessionId) {
|
||||||
|
|
||||||
const currentIndex = terminalIds.indexOf(terminalState.activeSessionId || "");
|
|
||||||
if (currentIndex === -1) {
|
|
||||||
// If no terminal is active, focus the first one
|
// If no terminal is active, focus the first one
|
||||||
setActiveTerminalSession(terminalIds[0]);
|
const terminalIds = getTerminalIds(activeTab.layout);
|
||||||
|
if (terminalIds.length > 0) {
|
||||||
|
setActiveTerminalSession(terminalIds[0]);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newIndex: number;
|
// Find the terminal in the given direction
|
||||||
if (direction === "next") {
|
// The algorithm traverses the layout tree to find spatially adjacent terminals
|
||||||
newIndex = (currentIndex + 1) % terminalIds.length;
|
const findTerminalInDirection = (
|
||||||
} else {
|
layout: TerminalPanelContent,
|
||||||
newIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length;
|
targetId: string,
|
||||||
}
|
dir: "up" | "down" | "left" | "right"
|
||||||
|
): string | null => {
|
||||||
|
// Helper to get all terminal IDs from a layout subtree
|
||||||
|
const getAllTerminals = (node: TerminalPanelContent): string[] => {
|
||||||
|
if (node.type === "terminal") return [node.sessionId];
|
||||||
|
return node.panels.flatMap(getAllTerminals);
|
||||||
|
};
|
||||||
|
|
||||||
setActiveTerminalSession(terminalIds[newIndex]);
|
// Helper to find terminal and its path in the tree
|
||||||
|
type PathEntry = { node: TerminalPanelContent; index: number; direction: "horizontal" | "vertical" };
|
||||||
|
const findPath = (
|
||||||
|
node: TerminalPanelContent,
|
||||||
|
target: string,
|
||||||
|
path: PathEntry[] = []
|
||||||
|
): PathEntry[] | null => {
|
||||||
|
if (node.type === "terminal") {
|
||||||
|
return node.sessionId === target ? path : null;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < node.panels.length; i++) {
|
||||||
|
const result = findPath(node.panels[i], target, [
|
||||||
|
...path,
|
||||||
|
{ node, index: i, direction: node.direction },
|
||||||
|
]);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const path = findPath(layout, targetId);
|
||||||
|
if (!path || path.length === 0) return null;
|
||||||
|
|
||||||
|
// Determine which split direction we need based on arrow direction
|
||||||
|
// left/right navigation works in "horizontal" splits (panels side by side)
|
||||||
|
// up/down navigation works in "vertical" splits (panels stacked)
|
||||||
|
const neededDirection = dir === "left" || dir === "right" ? "horizontal" : "vertical";
|
||||||
|
const goingForward = dir === "right" || dir === "down";
|
||||||
|
|
||||||
|
// Walk up the path to find a split in the right direction with an adjacent panel
|
||||||
|
for (let i = path.length - 1; i >= 0; i--) {
|
||||||
|
const entry = path[i];
|
||||||
|
if (entry.direction === neededDirection) {
|
||||||
|
const siblings = entry.node.type === "split" ? entry.node.panels : [];
|
||||||
|
const nextIndex = goingForward ? entry.index + 1 : entry.index - 1;
|
||||||
|
|
||||||
|
if (nextIndex >= 0 && nextIndex < siblings.length) {
|
||||||
|
// Found an adjacent panel in the right direction
|
||||||
|
const adjacentPanel = siblings[nextIndex];
|
||||||
|
const adjacentTerminals = getAllTerminals(adjacentPanel);
|
||||||
|
|
||||||
|
if (adjacentTerminals.length > 0) {
|
||||||
|
// When moving forward (right/down), pick the first terminal in that subtree
|
||||||
|
// When moving backward (left/up), pick the last terminal in that subtree
|
||||||
|
return goingForward
|
||||||
|
? adjacentTerminals[0]
|
||||||
|
: adjacentTerminals[adjacentTerminals.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextTerminal = findTerminalInDirection(activeTab.layout, currentSessionId, direction);
|
||||||
|
if (nextTerminal) {
|
||||||
|
setActiveTerminalSession(nextTerminal);
|
||||||
|
}
|
||||||
}, [activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]);
|
}, [activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]);
|
||||||
|
|
||||||
// Handle global keyboard shortcuts for pane navigation
|
// Handle global keyboard shortcuts for pane navigation
|
||||||
@@ -978,12 +1058,18 @@ export function TerminalView() {
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation
|
// Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation
|
||||||
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) {
|
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) {
|
||||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
if (e.key === "ArrowRight") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigateToTerminal("next");
|
navigateToTerminal("right");
|
||||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
} else if (e.key === "ArrowLeft") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigateToTerminal("prev");
|
navigateToTerminal("left");
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateToTerminal("down");
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateToTerminal("up");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1019,6 +1105,16 @@ export function TerminalView() {
|
|||||||
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
|
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
|
||||||
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
|
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
|
||||||
onNewTab={createTerminalInNewTab}
|
onNewTab={createTerminalInNewTab}
|
||||||
|
onNavigateUp={() => navigateToTerminal("up")}
|
||||||
|
onNavigateDown={() => navigateToTerminal("down")}
|
||||||
|
onNavigateLeft={() => navigateToTerminal("left")}
|
||||||
|
onNavigateRight={() => navigateToTerminal("right")}
|
||||||
|
onSessionInvalid={() => {
|
||||||
|
// Auto-remove stale session when server says it doesn't exist
|
||||||
|
// This handles cases like server restart where sessions are lost
|
||||||
|
console.log(`[Terminal] Session ${content.sessionId} is invalid, removing from layout`);
|
||||||
|
killTerminal(content.sessionId);
|
||||||
|
}}
|
||||||
isDragging={activeDragId === content.sessionId}
|
isDragging={activeDragId === content.sessionId}
|
||||||
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
@@ -1384,6 +1480,11 @@ export function TerminalView() {
|
|||||||
onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)}
|
onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)}
|
||||||
onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)}
|
onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)}
|
||||||
onNewTab={createTerminalInNewTab}
|
onNewTab={createTerminalInNewTab}
|
||||||
|
onSessionInvalid={() => {
|
||||||
|
const sessionId = terminalState.maximizedSessionId!;
|
||||||
|
console.log(`[Terminal] Maximized session ${sessionId} is invalid, removing from layout`);
|
||||||
|
killTerminal(sessionId);
|
||||||
|
}}
|
||||||
isDragging={false}
|
isDragging={false}
|
||||||
isDropTarget={false}
|
isDropTarget={false}
|
||||||
fontSize={findTerminalFontSize(terminalState.maximizedSessionId)}
|
fontSize={findTerminalFontSize(terminalState.maximizedSessionId)}
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, type KeyboardShortcuts } from "@/store/app-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { matchesShortcutWithCode } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { getTerminalTheme, TERMINAL_FONT_OPTIONS, DEFAULT_TERMINAL_FONT } from "@/config/terminal-themes";
|
import { getTerminalTheme, TERMINAL_FONT_OPTIONS, DEFAULT_TERMINAL_FONT } from "@/config/terminal-themes";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
@@ -62,6 +63,11 @@ interface TerminalPanelProps {
|
|||||||
onSplitHorizontal: () => void;
|
onSplitHorizontal: () => void;
|
||||||
onSplitVertical: () => void;
|
onSplitVertical: () => void;
|
||||||
onNewTab?: () => void;
|
onNewTab?: () => void;
|
||||||
|
onNavigateUp?: () => void; // Navigate to terminal pane above
|
||||||
|
onNavigateDown?: () => void; // Navigate to terminal pane below
|
||||||
|
onNavigateLeft?: () => void; // Navigate to terminal pane on the left
|
||||||
|
onNavigateRight?: () => void; // Navigate to terminal pane on the right
|
||||||
|
onSessionInvalid?: () => void; // Called when session is no longer valid on server (e.g., server restarted)
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
isDropTarget?: boolean;
|
isDropTarget?: boolean;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -87,6 +93,11 @@ export function TerminalPanel({
|
|||||||
onSplitHorizontal,
|
onSplitHorizontal,
|
||||||
onSplitVertical,
|
onSplitVertical,
|
||||||
onNewTab,
|
onNewTab,
|
||||||
|
onNavigateUp,
|
||||||
|
onNavigateDown,
|
||||||
|
onNavigateLeft,
|
||||||
|
onNavigateRight,
|
||||||
|
onSessionInvalid,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
isDropTarget = false,
|
isDropTarget = false,
|
||||||
fontSize,
|
fontSize,
|
||||||
@@ -177,6 +188,15 @@ export function TerminalPanel({
|
|||||||
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
|
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
|
||||||
const effectiveTheme = getEffectiveTheme();
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
|
||||||
|
// Get keyboard shortcuts from store - merged with defaults
|
||||||
|
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||||
|
const mergedShortcuts: KeyboardShortcuts = {
|
||||||
|
...DEFAULT_KEYBOARD_SHORTCUTS,
|
||||||
|
...keyboardShortcuts,
|
||||||
|
};
|
||||||
|
const shortcutsRef = useRef(mergedShortcuts);
|
||||||
|
shortcutsRef.current = mergedShortcuts;
|
||||||
|
|
||||||
// Track system dark mode preference for "system" theme
|
// Track system dark mode preference for "system" theme
|
||||||
const [systemIsDark, setSystemIsDark] = useState(() => {
|
const [systemIsDark, setSystemIsDark] = useState(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -212,6 +232,16 @@ export function TerminalPanel({
|
|||||||
onSplitVerticalRef.current = onSplitVertical;
|
onSplitVerticalRef.current = onSplitVertical;
|
||||||
const onNewTabRef = useRef(onNewTab);
|
const onNewTabRef = useRef(onNewTab);
|
||||||
onNewTabRef.current = onNewTab;
|
onNewTabRef.current = onNewTab;
|
||||||
|
const onNavigateUpRef = useRef(onNavigateUp);
|
||||||
|
onNavigateUpRef.current = onNavigateUp;
|
||||||
|
const onNavigateDownRef = useRef(onNavigateDown);
|
||||||
|
onNavigateDownRef.current = onNavigateDown;
|
||||||
|
const onNavigateLeftRef = useRef(onNavigateLeft);
|
||||||
|
onNavigateLeftRef.current = onNavigateLeft;
|
||||||
|
const onNavigateRightRef = useRef(onNavigateRight);
|
||||||
|
onNavigateRightRef.current = onNavigateRight;
|
||||||
|
const onSessionInvalidRef = useRef(onSessionInvalid);
|
||||||
|
onSessionInvalidRef.current = onSessionInvalid;
|
||||||
const fontSizeRef = useRef(fontSize);
|
const fontSizeRef = useRef(fontSize);
|
||||||
fontSizeRef.current = fontSize;
|
fontSizeRef.current = fontSize;
|
||||||
const themeRef = useRef(resolvedTheme);
|
const themeRef = useRef(resolvedTheme);
|
||||||
@@ -348,18 +378,33 @@ export function TerminalPanel({
|
|||||||
xtermRef.current?.clear();
|
xtermRef.current?.clear();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Get theme colors for search highlighting
|
||||||
|
const terminalTheme = getTerminalTheme(effectiveTheme);
|
||||||
|
const searchOptions = {
|
||||||
|
caseSensitive: false,
|
||||||
|
regex: false,
|
||||||
|
decorations: {
|
||||||
|
matchBackground: terminalTheme.searchMatchBackground,
|
||||||
|
matchBorder: terminalTheme.searchMatchBorder,
|
||||||
|
matchOverviewRuler: terminalTheme.searchMatchBorder,
|
||||||
|
activeMatchBackground: terminalTheme.searchActiveMatchBackground,
|
||||||
|
activeMatchBorder: terminalTheme.searchActiveMatchBorder,
|
||||||
|
activeMatchColorOverviewRuler: terminalTheme.searchActiveMatchBorder,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Search functions
|
// Search functions
|
||||||
const searchNext = useCallback(() => {
|
const searchNext = useCallback(() => {
|
||||||
if (searchAddonRef.current && searchQuery) {
|
if (searchAddonRef.current && searchQuery) {
|
||||||
searchAddonRef.current.findNext(searchQuery, { caseSensitive: false, regex: false });
|
searchAddonRef.current.findNext(searchQuery, searchOptions);
|
||||||
}
|
}
|
||||||
}, [searchQuery]);
|
}, [searchQuery, searchOptions]);
|
||||||
|
|
||||||
const searchPrevious = useCallback(() => {
|
const searchPrevious = useCallback(() => {
|
||||||
if (searchAddonRef.current && searchQuery) {
|
if (searchAddonRef.current && searchQuery) {
|
||||||
searchAddonRef.current.findPrevious(searchQuery, { caseSensitive: false, regex: false });
|
searchAddonRef.current.findPrevious(searchQuery, searchOptions);
|
||||||
}
|
}
|
||||||
}, [searchQuery]);
|
}, [searchQuery, searchOptions]);
|
||||||
|
|
||||||
const closeSearch = useCallback(() => {
|
const closeSearch = useCallback(() => {
|
||||||
setShowSearch(false);
|
setShowSearch(false);
|
||||||
@@ -369,6 +414,32 @@ export function TerminalPanel({
|
|||||||
xtermRef.current?.focus();
|
xtermRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle pane navigation keyboard shortcuts at container level (capture phase)
|
||||||
|
// This ensures we intercept before xterm can process the event
|
||||||
|
const handleContainerKeyDownCapture = useCallback((event: React.KeyboardEvent) => {
|
||||||
|
// Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) {
|
||||||
|
const code = event.nativeEvent.code;
|
||||||
|
if (code === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigateRight?.();
|
||||||
|
} else if (code === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigateLeft?.();
|
||||||
|
} else if (code === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigateDown?.();
|
||||||
|
} else if (code === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigateUp?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onNavigateUp, onNavigateDown, onNavigateLeft, onNavigateRight]);
|
||||||
|
|
||||||
// Scroll to bottom of terminal
|
// Scroll to bottom of terminal
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
if (xtermRef.current) {
|
if (xtermRef.current) {
|
||||||
@@ -482,8 +553,21 @@ export function TerminalPanel({
|
|||||||
terminal.loadAddon(searchAddon);
|
terminal.loadAddon(searchAddon);
|
||||||
searchAddonRef.current = searchAddon;
|
searchAddonRef.current = searchAddon;
|
||||||
|
|
||||||
// Create web links addon for clickable URLs
|
// Create web links addon for clickable URLs with custom handler for Electron
|
||||||
const webLinksAddon = new WebLinksAddon();
|
const webLinksAddon = new WebLinksAddon((_event: MouseEvent, uri: string) => {
|
||||||
|
// Use Electron API to open external links in system browser
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.openExternalLink) {
|
||||||
|
api.openExternalLink(uri).catch((error) => {
|
||||||
|
console.error("[Terminal] Failed to open URL:", error);
|
||||||
|
// Fallback to window.open if Electron API fails
|
||||||
|
window.open(uri, "_blank", "noopener,noreferrer");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Web fallback
|
||||||
|
window.open(uri, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
});
|
||||||
terminal.loadAddon(webLinksAddon);
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
// Open terminal
|
// Open terminal
|
||||||
@@ -661,15 +745,45 @@ export function TerminalPanel({
|
|||||||
// Only intercept keydown events
|
// Only intercept keydown events
|
||||||
if (event.type !== 'keydown') return true;
|
if (event.type !== 'keydown') return true;
|
||||||
|
|
||||||
|
// Use event.code for keyboard-layout-independent key detection
|
||||||
|
const code = event.code;
|
||||||
|
|
||||||
|
// Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally
|
||||||
|
// Handle this FIRST before any other checks to prevent xterm from capturing it
|
||||||
|
// Use explicit check for both Ctrl and Meta to work on all platforms
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) {
|
||||||
|
if (code === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigateRightRef.current?.();
|
||||||
|
return false;
|
||||||
|
} else if (code === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigateLeftRef.current?.();
|
||||||
|
return false;
|
||||||
|
} else if (code === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigateDownRef.current?.();
|
||||||
|
return false;
|
||||||
|
} else if (code === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onNavigateUpRef.current?.();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check cooldown to prevent rapid terminal creation
|
// Check cooldown to prevent rapid terminal creation
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS;
|
const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS;
|
||||||
|
|
||||||
// Use event.code for keyboard-layout-independent key detection
|
// Get current shortcuts from ref (allows customization)
|
||||||
const code = event.code;
|
const shortcuts = shortcutsRef.current;
|
||||||
|
|
||||||
// Alt+D - Split right
|
// Split right (default: Alt+D)
|
||||||
if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyD') {
|
if (matchesShortcutWithCode(event, shortcuts.splitTerminalRight)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (canTrigger) {
|
if (canTrigger) {
|
||||||
lastShortcutTimeRef.current = now;
|
lastShortcutTimeRef.current = now;
|
||||||
@@ -678,8 +792,8 @@ export function TerminalPanel({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+S - Split down
|
// Split down (default: Alt+S)
|
||||||
if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyS') {
|
if (matchesShortcutWithCode(event, shortcuts.splitTerminalDown)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (canTrigger) {
|
if (canTrigger) {
|
||||||
lastShortcutTimeRef.current = now;
|
lastShortcutTimeRef.current = now;
|
||||||
@@ -688,8 +802,8 @@ export function TerminalPanel({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+W - Close terminal
|
// Close terminal (default: Alt+W)
|
||||||
if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyW') {
|
if (matchesShortcutWithCode(event, shortcuts.closeTerminal)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (canTrigger) {
|
if (canTrigger) {
|
||||||
lastShortcutTimeRef.current = now;
|
lastShortcutTimeRef.current = now;
|
||||||
@@ -698,8 +812,8 @@ export function TerminalPanel({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+T - New terminal tab
|
// New terminal tab (default: Alt+T)
|
||||||
if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyT') {
|
if (matchesShortcutWithCode(event, shortcuts.newTerminalTab)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (canTrigger && onNewTabRef.current) {
|
if (canTrigger && onNewTabRef.current) {
|
||||||
lastShortcutTimeRef.current = now;
|
lastShortcutTimeRef.current = now;
|
||||||
@@ -843,11 +957,20 @@ export function TerminalPanel({
|
|||||||
hasRunInitialCommandRef.current = true;
|
hasRunInitialCommandRef.current = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "connected":
|
case "connected": {
|
||||||
console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`);
|
console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`);
|
||||||
|
// Detect shell type from path
|
||||||
|
const shellPath = (msg.shell || "").toLowerCase();
|
||||||
|
// Windows shells use backslash paths and include powershell/pwsh/cmd
|
||||||
|
const isWindowsShell = shellPath.includes("\\") ||
|
||||||
|
shellPath.includes("powershell") ||
|
||||||
|
shellPath.includes("pwsh") ||
|
||||||
|
shellPath.includes("cmd.exe");
|
||||||
|
const isPowerShell = shellPath.includes("powershell") || shellPath.includes("pwsh");
|
||||||
|
|
||||||
if (msg.shell) {
|
if (msg.shell) {
|
||||||
// Extract shell name from path (e.g., "/bin/bash" -> "bash")
|
// Extract shell name from path (e.g., "/bin/bash" -> "bash", "C:\...\powershell.exe" -> "powershell.exe")
|
||||||
const name = msg.shell.split("/").pop() || msg.shell;
|
const name = msg.shell.split(/[/\\]/).pop() || msg.shell;
|
||||||
setShellName(name);
|
setShellName(name);
|
||||||
}
|
}
|
||||||
// Run initial command if specified and not already run
|
// Run initial command if specified and not already run
|
||||||
@@ -858,15 +981,22 @@ export function TerminalPanel({
|
|||||||
ws.readyState === WebSocket.OPEN
|
ws.readyState === WebSocket.OPEN
|
||||||
) {
|
) {
|
||||||
hasRunInitialCommandRef.current = true;
|
hasRunInitialCommandRef.current = true;
|
||||||
// Small delay to let the shell prompt appear
|
// Use appropriate line ending for the shell type
|
||||||
|
// Windows shells (PowerShell, cmd) expect \r\n, Unix shells expect \n
|
||||||
|
const lineEnding = isWindowsShell ? "\r\n" : "\n";
|
||||||
|
// PowerShell takes longer to initialize (profile loading, etc.)
|
||||||
|
// Use 500ms for PowerShell, 100ms for other shells
|
||||||
|
const delay = isPowerShell ? 500 : 100;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + "\n" }));
|
ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + lineEnding }));
|
||||||
onCommandRan?.();
|
onCommandRan?.();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, delay);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "exit":
|
case "exit":
|
||||||
terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`);
|
terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`);
|
||||||
setProcessExitCode(msg.exitCode);
|
setProcessExitCode(msg.exitCode);
|
||||||
@@ -907,10 +1037,20 @@ export function TerminalPanel({
|
|||||||
|
|
||||||
if (event.code === 4004) {
|
if (event.code === 4004) {
|
||||||
setConnectionStatus("disconnected");
|
setConnectionStatus("disconnected");
|
||||||
toast.error("Terminal session not found", {
|
// Notify parent that this session is no longer valid on the server
|
||||||
description: "The session may have expired. Please create a new terminal.",
|
// This allows automatic cleanup of stale sessions (e.g., after server restart)
|
||||||
duration: 5000,
|
if (onSessionInvalidRef.current) {
|
||||||
});
|
onSessionInvalidRef.current();
|
||||||
|
toast.info("Terminal session expired", {
|
||||||
|
description: "The session was automatically removed. Create a new terminal to continue.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error("Terminal session not found", {
|
||||||
|
description: "The session may have expired. Please create a new terminal.",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,6 +1612,7 @@ export function TerminalPanel({
|
|||||||
isOver && isDropTarget && "ring-2 ring-green-500 ring-inset"
|
isOver && isDropTarget && "ring-2 ring-green-500 ring-inset"
|
||||||
)}
|
)}
|
||||||
onClick={onFocus}
|
onClick={onFocus}
|
||||||
|
onKeyDownCapture={handleContainerKeyDownCapture}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
data-terminal-container="true"
|
data-terminal-container="true"
|
||||||
>
|
>
|
||||||
@@ -1818,7 +1959,7 @@ export function TerminalPanel({
|
|||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
// Auto-search as user types
|
// Auto-search as user types
|
||||||
if (searchAddonRef.current && e.target.value) {
|
if (searchAddonRef.current && e.target.value) {
|
||||||
searchAddonRef.current.findNext(e.target.value, { caseSensitive: false, regex: false });
|
searchAddonRef.current.findNext(e.target.value, searchOptions);
|
||||||
} else if (searchAddonRef.current) {
|
} else if (searchAddonRef.current) {
|
||||||
searchAddonRef.current.clearDecorations();
|
searchAddonRef.current.clearDecorations();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export interface TerminalTheme {
|
|||||||
brightMagenta: string;
|
brightMagenta: string;
|
||||||
brightCyan: string;
|
brightCyan: string;
|
||||||
brightWhite: string;
|
brightWhite: string;
|
||||||
|
// Search highlighting colors - for xterm SearchAddon
|
||||||
|
searchMatchBackground: string;
|
||||||
|
searchMatchBorder: string;
|
||||||
|
searchActiveMatchBackground: string;
|
||||||
|
searchActiveMatchBorder: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +82,11 @@ const darkTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#c586c0",
|
brightMagenta: "#c586c0",
|
||||||
brightCyan: "#4ec9b0",
|
brightCyan: "#4ec9b0",
|
||||||
brightWhite: "#ffffff",
|
brightWhite: "#ffffff",
|
||||||
|
// Search colors - bright yellow for visibility on dark background
|
||||||
|
searchMatchBackground: "#6b5300",
|
||||||
|
searchMatchBorder: "#e2ac00",
|
||||||
|
searchActiveMatchBackground: "#ff8c00",
|
||||||
|
searchActiveMatchBorder: "#ffb74d",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Light theme
|
// Light theme
|
||||||
@@ -102,6 +112,11 @@ const lightTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#c678dd",
|
brightMagenta: "#c678dd",
|
||||||
brightCyan: "#56b6c2",
|
brightCyan: "#56b6c2",
|
||||||
brightWhite: "#ffffff",
|
brightWhite: "#ffffff",
|
||||||
|
// Search colors - darker for visibility on light background
|
||||||
|
searchMatchBackground: "#fff3b0",
|
||||||
|
searchMatchBorder: "#c9a500",
|
||||||
|
searchActiveMatchBackground: "#ffcc00",
|
||||||
|
searchActiveMatchBorder: "#996600",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Retro / Cyberpunk theme - neon green on black
|
// Retro / Cyberpunk theme - neon green on black
|
||||||
@@ -128,6 +143,11 @@ const retroTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#ff55ff",
|
brightMagenta: "#ff55ff",
|
||||||
brightCyan: "#55ffff",
|
brightCyan: "#55ffff",
|
||||||
brightWhite: "#ffffff",
|
brightWhite: "#ffffff",
|
||||||
|
// Search colors - magenta/pink for contrast with green text
|
||||||
|
searchMatchBackground: "#660066",
|
||||||
|
searchMatchBorder: "#ff00ff",
|
||||||
|
searchActiveMatchBackground: "#cc00cc",
|
||||||
|
searchActiveMatchBorder: "#ff66ff",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dracula theme
|
// Dracula theme
|
||||||
@@ -153,6 +173,11 @@ const draculaTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#ff92df",
|
brightMagenta: "#ff92df",
|
||||||
brightCyan: "#a4ffff",
|
brightCyan: "#a4ffff",
|
||||||
brightWhite: "#ffffff",
|
brightWhite: "#ffffff",
|
||||||
|
// Search colors - orange for visibility
|
||||||
|
searchMatchBackground: "#8b5a00",
|
||||||
|
searchMatchBorder: "#ffb86c",
|
||||||
|
searchActiveMatchBackground: "#ff9500",
|
||||||
|
searchActiveMatchBorder: "#ffcc80",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Nord theme
|
// Nord theme
|
||||||
@@ -178,6 +203,11 @@ const nordTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#b48ead",
|
brightMagenta: "#b48ead",
|
||||||
brightCyan: "#8fbcbb",
|
brightCyan: "#8fbcbb",
|
||||||
brightWhite: "#eceff4",
|
brightWhite: "#eceff4",
|
||||||
|
// Search colors - warm yellow/orange for cold blue theme
|
||||||
|
searchMatchBackground: "#5e4a00",
|
||||||
|
searchMatchBorder: "#ebcb8b",
|
||||||
|
searchActiveMatchBackground: "#d08770",
|
||||||
|
searchActiveMatchBorder: "#e8a87a",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Monokai theme
|
// Monokai theme
|
||||||
@@ -203,6 +233,11 @@ const monokaiTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#ae81ff",
|
brightMagenta: "#ae81ff",
|
||||||
brightCyan: "#a1efe4",
|
brightCyan: "#a1efe4",
|
||||||
brightWhite: "#f9f8f5",
|
brightWhite: "#f9f8f5",
|
||||||
|
// Search colors - orange/gold for contrast
|
||||||
|
searchMatchBackground: "#6b4400",
|
||||||
|
searchMatchBorder: "#f4bf75",
|
||||||
|
searchActiveMatchBackground: "#e69500",
|
||||||
|
searchActiveMatchBorder: "#ffd080",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tokyo Night theme
|
// Tokyo Night theme
|
||||||
@@ -228,6 +263,11 @@ const tokyonightTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#bb9af7",
|
brightMagenta: "#bb9af7",
|
||||||
brightCyan: "#7dcfff",
|
brightCyan: "#7dcfff",
|
||||||
brightWhite: "#c0caf5",
|
brightWhite: "#c0caf5",
|
||||||
|
// Search colors - warm orange for cold blue theme
|
||||||
|
searchMatchBackground: "#5c4a00",
|
||||||
|
searchMatchBorder: "#e0af68",
|
||||||
|
searchActiveMatchBackground: "#ff9e64",
|
||||||
|
searchActiveMatchBorder: "#ffb380",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Solarized Dark theme (improved contrast for WCAG compliance)
|
// Solarized Dark theme (improved contrast for WCAG compliance)
|
||||||
@@ -253,6 +293,11 @@ const solarizedTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#6c71c4",
|
brightMagenta: "#6c71c4",
|
||||||
brightCyan: "#93a1a1",
|
brightCyan: "#93a1a1",
|
||||||
brightWhite: "#fdf6e3",
|
brightWhite: "#fdf6e3",
|
||||||
|
// Search colors - orange (solarized orange) for visibility
|
||||||
|
searchMatchBackground: "#5c3d00",
|
||||||
|
searchMatchBorder: "#b58900",
|
||||||
|
searchActiveMatchBackground: "#cb4b16",
|
||||||
|
searchActiveMatchBorder: "#e07040",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gruvbox Dark theme
|
// Gruvbox Dark theme
|
||||||
@@ -278,6 +323,11 @@ const gruvboxTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#d3869b",
|
brightMagenta: "#d3869b",
|
||||||
brightCyan: "#8ec07c",
|
brightCyan: "#8ec07c",
|
||||||
brightWhite: "#ebdbb2",
|
brightWhite: "#ebdbb2",
|
||||||
|
// Search colors - bright orange for gruvbox
|
||||||
|
searchMatchBackground: "#6b4500",
|
||||||
|
searchMatchBorder: "#d79921",
|
||||||
|
searchActiveMatchBackground: "#fe8019",
|
||||||
|
searchActiveMatchBorder: "#ffaa40",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Catppuccin Mocha theme
|
// Catppuccin Mocha theme
|
||||||
@@ -303,6 +353,11 @@ const catppuccinTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#cba6f7",
|
brightMagenta: "#cba6f7",
|
||||||
brightCyan: "#94e2d5",
|
brightCyan: "#94e2d5",
|
||||||
brightWhite: "#a6adc8",
|
brightWhite: "#a6adc8",
|
||||||
|
// Search colors - peach/orange from catppuccin palette
|
||||||
|
searchMatchBackground: "#5c4020",
|
||||||
|
searchMatchBorder: "#fab387",
|
||||||
|
searchActiveMatchBackground: "#fab387",
|
||||||
|
searchActiveMatchBorder: "#fcc8a0",
|
||||||
};
|
};
|
||||||
|
|
||||||
// One Dark theme
|
// One Dark theme
|
||||||
@@ -328,6 +383,11 @@ const onedarkTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#c678dd",
|
brightMagenta: "#c678dd",
|
||||||
brightCyan: "#56b6c2",
|
brightCyan: "#56b6c2",
|
||||||
brightWhite: "#ffffff",
|
brightWhite: "#ffffff",
|
||||||
|
// Search colors - orange/gold for visibility
|
||||||
|
searchMatchBackground: "#5c4500",
|
||||||
|
searchMatchBorder: "#e5c07b",
|
||||||
|
searchActiveMatchBackground: "#d19a66",
|
||||||
|
searchActiveMatchBorder: "#e8b888",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Synthwave '84 theme
|
// Synthwave '84 theme
|
||||||
@@ -353,6 +413,11 @@ const synthwaveTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#ff7edb",
|
brightMagenta: "#ff7edb",
|
||||||
brightCyan: "#03edf9",
|
brightCyan: "#03edf9",
|
||||||
brightWhite: "#ffffff",
|
brightWhite: "#ffffff",
|
||||||
|
// Search colors - hot pink/magenta for synthwave aesthetic
|
||||||
|
searchMatchBackground: "#6b2a7a",
|
||||||
|
searchMatchBorder: "#ff7edb",
|
||||||
|
searchActiveMatchBackground: "#ff7edb",
|
||||||
|
searchActiveMatchBorder: "#ffffff",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Red theme - Dark theme with red accents
|
// Red theme - Dark theme with red accents
|
||||||
@@ -378,6 +443,11 @@ const redTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#cc77aa",
|
brightMagenta: "#cc77aa",
|
||||||
brightCyan: "#77aaaa",
|
brightCyan: "#77aaaa",
|
||||||
brightWhite: "#d0c0c0",
|
brightWhite: "#d0c0c0",
|
||||||
|
// Search colors - orange/gold to contrast with red theme
|
||||||
|
searchMatchBackground: "#5a3520",
|
||||||
|
searchMatchBorder: "#ccaa55",
|
||||||
|
searchActiveMatchBackground: "#ddbb66",
|
||||||
|
searchActiveMatchBorder: "#ffdd88",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cream theme - Warm, soft, easy on the eyes
|
// Cream theme - Warm, soft, easy on the eyes
|
||||||
@@ -403,6 +473,11 @@ const creamTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#c080a0",
|
brightMagenta: "#c080a0",
|
||||||
brightCyan: "#70b0a0",
|
brightCyan: "#70b0a0",
|
||||||
brightWhite: "#d0c0b0",
|
brightWhite: "#d0c0b0",
|
||||||
|
// Search colors - blue for contrast on light cream background
|
||||||
|
searchMatchBackground: "#c0d4e8",
|
||||||
|
searchMatchBorder: "#6b8aaa",
|
||||||
|
searchActiveMatchBackground: "#6b8aaa",
|
||||||
|
searchActiveMatchBorder: "#4a6a8a",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sunset theme - Mellow oranges and soft pastels
|
// Sunset theme - Mellow oranges and soft pastels
|
||||||
@@ -428,6 +503,11 @@ const sunsetTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#dd88aa",
|
brightMagenta: "#dd88aa",
|
||||||
brightCyan: "#88ddbb",
|
brightCyan: "#88ddbb",
|
||||||
brightWhite: "#f5e8dd",
|
brightWhite: "#f5e8dd",
|
||||||
|
// Search colors - orange for warm sunset theme
|
||||||
|
searchMatchBackground: "#5a3a30",
|
||||||
|
searchMatchBorder: "#ddaa66",
|
||||||
|
searchActiveMatchBackground: "#eebb77",
|
||||||
|
searchActiveMatchBorder: "#ffdd99",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gray theme - Modern, minimal gray scheme inspired by Cursor
|
// Gray theme - Modern, minimal gray scheme inspired by Cursor
|
||||||
@@ -453,6 +533,11 @@ const grayTheme: TerminalTheme = {
|
|||||||
brightMagenta: "#c098c8",
|
brightMagenta: "#c098c8",
|
||||||
brightCyan: "#80b8c8",
|
brightCyan: "#80b8c8",
|
||||||
brightWhite: "#e0e0e8",
|
brightWhite: "#e0e0e8",
|
||||||
|
// Search colors - blue for modern feel
|
||||||
|
searchMatchBackground: "#3a4a60",
|
||||||
|
searchMatchBorder: "#7090c0",
|
||||||
|
searchActiveMatchBackground: "#90b0d8",
|
||||||
|
searchActiveMatchBorder: "#b0d0f0",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Theme mapping
|
// Theme mapping
|
||||||
|
|||||||
@@ -77,6 +77,102 @@ function isInputFocused(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a key character to its corresponding event.code
|
||||||
|
* This is used for keyboard-layout independent matching in terminals
|
||||||
|
*/
|
||||||
|
function keyToCode(key: string): string {
|
||||||
|
const upperKey = key.toUpperCase();
|
||||||
|
|
||||||
|
// Letters A-Z map to KeyA-KeyZ
|
||||||
|
if (/^[A-Z]$/.test(upperKey)) {
|
||||||
|
return `Key${upperKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbers 0-9 on main row map to Digit0-Digit9
|
||||||
|
if (/^[0-9]$/.test(key)) {
|
||||||
|
return `Digit${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special key mappings
|
||||||
|
const specialMappings: Record<string, string> = {
|
||||||
|
"`": "Backquote",
|
||||||
|
"~": "Backquote",
|
||||||
|
"-": "Minus",
|
||||||
|
"_": "Minus",
|
||||||
|
"=": "Equal",
|
||||||
|
"+": "Equal",
|
||||||
|
"[": "BracketLeft",
|
||||||
|
"{": "BracketLeft",
|
||||||
|
"]": "BracketRight",
|
||||||
|
"}": "BracketRight",
|
||||||
|
"\\": "Backslash",
|
||||||
|
"|": "Backslash",
|
||||||
|
";": "Semicolon",
|
||||||
|
":": "Semicolon",
|
||||||
|
"'": "Quote",
|
||||||
|
'"': "Quote",
|
||||||
|
",": "Comma",
|
||||||
|
"<": "Comma",
|
||||||
|
".": "Period",
|
||||||
|
">": "Period",
|
||||||
|
"/": "Slash",
|
||||||
|
"?": "Slash",
|
||||||
|
" ": "Space",
|
||||||
|
"Enter": "Enter",
|
||||||
|
"Tab": "Tab",
|
||||||
|
"Escape": "Escape",
|
||||||
|
"Backspace": "Backspace",
|
||||||
|
"Delete": "Delete",
|
||||||
|
"ArrowUp": "ArrowUp",
|
||||||
|
"ArrowDown": "ArrowDown",
|
||||||
|
"ArrowLeft": "ArrowLeft",
|
||||||
|
"ArrowRight": "ArrowRight",
|
||||||
|
};
|
||||||
|
|
||||||
|
return specialMappings[key] || specialMappings[upperKey] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a keyboard event matches a shortcut definition using event.code
|
||||||
|
* This is keyboard-layout independent - useful for terminals where Alt+key
|
||||||
|
* combinations can produce special characters with event.key
|
||||||
|
*/
|
||||||
|
export function matchesShortcutWithCode(event: KeyboardEvent, shortcutStr: string): boolean {
|
||||||
|
const shortcut = parseShortcut(shortcutStr);
|
||||||
|
if (!shortcut.key) return false;
|
||||||
|
|
||||||
|
// Convert the shortcut key to event.code format
|
||||||
|
const expectedCode = keyToCode(shortcut.key);
|
||||||
|
|
||||||
|
// Check if the code matches
|
||||||
|
if (event.code !== expectedCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check modifier keys
|
||||||
|
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
|
||||||
|
const shiftPressed = event.shiftKey;
|
||||||
|
const altPressed = event.altKey;
|
||||||
|
|
||||||
|
// If shortcut requires cmdCtrl, it must be pressed
|
||||||
|
if (shortcut.cmdCtrl && !cmdCtrlPressed) return false;
|
||||||
|
// If shortcut doesn't require cmdCtrl, it shouldn't be pressed
|
||||||
|
if (!shortcut.cmdCtrl && cmdCtrlPressed) return false;
|
||||||
|
|
||||||
|
// If shortcut requires shift, it must be pressed
|
||||||
|
if (shortcut.shift && !shiftPressed) return false;
|
||||||
|
// If shortcut doesn't require shift, it shouldn't be pressed
|
||||||
|
if (!shortcut.shift && shiftPressed) return false;
|
||||||
|
|
||||||
|
// If shortcut requires alt, it must be pressed
|
||||||
|
if (shortcut.alt && !altPressed) return false;
|
||||||
|
// If shortcut doesn't require alt, it shouldn't be pressed
|
||||||
|
if (!shortcut.alt && altPressed) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a keyboard event matches a shortcut definition
|
* Check if a keyboard event matches a shortcut definition
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2239,6 +2239,9 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
...current,
|
...current,
|
||||||
activeTabId: tabId,
|
activeTabId: tabId,
|
||||||
activeSessionId: newActiveSessionId,
|
activeSessionId: newActiveSessionId,
|
||||||
|
// Clear maximized state when switching tabs - the maximized terminal
|
||||||
|
// belongs to the previous tab and shouldn't persist across tab switches
|
||||||
|
maximizedSessionId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -2636,6 +2639,36 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
{
|
{
|
||||||
name: "automaker-storage",
|
name: "automaker-storage",
|
||||||
version: 2, // Increment when making breaking changes to persisted state
|
version: 2, // Increment when making breaking changes to persisted state
|
||||||
|
// Custom merge function to properly restore terminal settings on every load
|
||||||
|
// The default shallow merge doesn't work because we persist terminalSettings
|
||||||
|
// separately from terminalState (to avoid persisting session data like tabs)
|
||||||
|
merge: (persistedState, currentState) => {
|
||||||
|
const persisted = persistedState as Partial<AppState> & { terminalSettings?: PersistedTerminalSettings };
|
||||||
|
const current = currentState as AppState & AppActions;
|
||||||
|
|
||||||
|
// Start with default shallow merge
|
||||||
|
const merged = { ...current, ...persisted } as AppState & AppActions;
|
||||||
|
|
||||||
|
// Restore terminal settings into terminalState
|
||||||
|
// terminalSettings is persisted separately from terminalState to avoid
|
||||||
|
// persisting session data (tabs, activeSessionId, etc.)
|
||||||
|
if (persisted.terminalSettings) {
|
||||||
|
merged.terminalState = {
|
||||||
|
// Start with current (initial) terminalState for session fields
|
||||||
|
...current.terminalState,
|
||||||
|
// Override with persisted settings
|
||||||
|
defaultFontSize: persisted.terminalSettings.defaultFontSize ?? current.terminalState.defaultFontSize,
|
||||||
|
defaultRunScript: persisted.terminalSettings.defaultRunScript ?? current.terminalState.defaultRunScript,
|
||||||
|
screenReaderMode: persisted.terminalSettings.screenReaderMode ?? current.terminalState.screenReaderMode,
|
||||||
|
fontFamily: persisted.terminalSettings.fontFamily ?? current.terminalState.fontFamily,
|
||||||
|
scrollbackLines: persisted.terminalSettings.scrollbackLines ?? current.terminalState.scrollbackLines,
|
||||||
|
lineHeight: persisted.terminalSettings.lineHeight ?? current.terminalState.lineHeight,
|
||||||
|
maxSessions: persisted.terminalSettings.maxSessions ?? current.terminalState.maxSessions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
},
|
||||||
migrate: (persistedState: unknown, version: number) => {
|
migrate: (persistedState: unknown, version: number) => {
|
||||||
const state = persistedState as Partial<AppState>;
|
const state = persistedState as Partial<AppState>;
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,27 @@ When password protection is enabled:
|
|||||||
|
|
||||||
When the terminal is focused, the following shortcuts are available:
|
When the terminal is focused, the following shortcuts are available:
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| -------- | --------------------------------------- |
|
| -------- | ---------------------------------------- |
|
||||||
| `Alt+D` | Split terminal right (horizontal split) |
|
| `Alt+T` | Open new terminal tab |
|
||||||
| `Alt+S` | Split terminal down (vertical split) |
|
| `Alt+D` | Split terminal right (horizontal split) |
|
||||||
| `Alt+W` | Close current terminal |
|
| `Alt+S` | Split terminal down (vertical split) |
|
||||||
|
| `Alt+W` | Close current terminal |
|
||||||
|
|
||||||
|
These shortcuts are customizable via the keyboard shortcuts settings (Settings > Keyboard Shortcuts).
|
||||||
|
|
||||||
|
### Split Pane Navigation
|
||||||
|
|
||||||
|
Navigate between terminal panes using directional shortcuts:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| --------------------------------- | ----------------------------------- |
|
||||||
|
| `Ctrl+Alt+ArrowUp` (or `Cmd+Alt`) | Move focus to terminal pane above |
|
||||||
|
| `Ctrl+Alt+ArrowDown` | Move focus to terminal pane below |
|
||||||
|
| `Ctrl+Alt+ArrowLeft` | Move focus to terminal pane on left |
|
||||||
|
| `Ctrl+Alt+ArrowRight` | Move focus to terminal pane on right|
|
||||||
|
|
||||||
|
The navigation is spatially aware - pressing Down will move to the terminal below your current one, not just cycle through terminals in order.
|
||||||
|
|
||||||
Global shortcut (works anywhere in the app):
|
Global shortcut (works anywhere in the app):
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|
|||||||
Reference in New Issue
Block a user