diff --git a/CLAUDE.md b/CLAUDE.md index de51ce75c6d..1e62a732427 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,3 +178,47 @@ Each instance needs: - **Separate user data directory** - Pass via `--user-data-dir` flag The desktop app loads environment variables from the monorepo root `.env` file. + +### Keyboard Shortcuts System + +The desktop app uses a centralized keyboard shortcuts system inspired by Arc Browser. + +**File Structure:** +- `src/renderer/lib/keyboard-shortcuts.ts` - Core shortcuts infrastructure (types, matchers, handlers) +- `src/renderer/lib/shortcuts.ts` - Arc-style shortcut definitions (workspace, tab, terminal) + +**Implemented Shortcuts:** + +**Workspace Management:** +- `Cmd+Option+Left/Right` - Switch between workspaces +- `Cmd+S` - Toggle sidebar visibility +- `Cmd+D` - Create split view (horizontal) +- `Cmd+Shift+D` - Create split view (vertical) + +**Tab Management:** +- `Cmd+Option+Up/Down` - Switch between tabs +- `Cmd+T` - Create new tab +- `Cmd+W` - Close tab +- `Cmd+Shift+T` - Reopen closed tab [TODO - requires history tracking] +- `Cmd+1-9` - Jump to tab by position + +**Terminal:** +- `Cmd+K` - Clear terminal (scrollback + screen) + +**Adding New Shortcuts:** + +1. Define handlers in the component (e.g., `MainScreen.tsx`) +2. Create shortcut group using helper functions from `shortcuts.ts` +3. Use `createShortcutHandler` to convert to event handler +4. Attach to event listener or terminal custom key handler + +**Example:** +```typescript +const shortcuts = createWorkspaceShortcuts({ + switchToPrevWorkspace: () => { /* handler logic */ }, + // ... other handlers +}); + +const handleKeyDown = createShortcutHandler(shortcuts.shortcuts); +window.addEventListener("keydown", handleKeyDown); +``` diff --git a/apps/desktop/src/main/lib/terminal.ts b/apps/desktop/src/main/lib/terminal.ts index baa30b6ceaa..4c7d4ad4532 100644 --- a/apps/desktop/src/main/lib/terminal.ts +++ b/apps/desktop/src/main/lib/terminal.ts @@ -74,6 +74,19 @@ class TerminalManager { this.addTerminalMessage(id, data); }); + // Handle terminal exit + ptyProcess.onExit(({ exitCode }) => { + console.log(`Terminal ${id} exited with code ${exitCode}`); + // Notify renderer that terminal has exited + this.mainWindow?.webContents.send("terminal-exited", { + id, + exitCode, + }); + // Clean up + this.processes.delete(id); + this.outputHistory.delete(id); + }); + this.processes.set(id, ptyProcess); return id; } catch (error) { diff --git a/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts b/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts new file mode 100644 index 00000000000..d0a3de242dc --- /dev/null +++ b/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts @@ -0,0 +1,97 @@ +/** + * Keyboard shortcuts module + * Central place for all keyboard shortcut definitions and handling + */ + +export type ModifierKey = "meta" | "ctrl" | "alt" | "shift"; + +export interface KeyboardShortcut { + key: string; + modifiers?: ModifierKey[]; + description: string; + handler: (event: KeyboardEvent) => boolean | void; +} + +export interface KeyboardShortcutGroup { + name: string; + shortcuts: KeyboardShortcut[]; +} + +/** + * Check if event matches the shortcut definition + */ +export function matchesShortcut( + event: KeyboardEvent, + shortcut: KeyboardShortcut, +): boolean { + // Check key match (case insensitive) + if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) { + return false; + } + + // Check modifiers + const modifiers = shortcut.modifiers || []; + const hasCtrl = modifiers.includes("ctrl"); + const hasMeta = modifiers.includes("meta"); + const hasAlt = modifiers.includes("alt"); + const hasShift = modifiers.includes("shift"); + + return ( + event.ctrlKey === hasCtrl && + event.metaKey === hasMeta && + event.altKey === hasAlt && + event.shiftKey === hasShift + ); +} + +/** + * Create a keyboard event handler that processes multiple shortcuts + */ +export function createShortcutHandler(shortcuts: KeyboardShortcut[]) { + return (event: KeyboardEvent): boolean => { + for (const shortcut of shortcuts) { + if (matchesShortcut(event, shortcut)) { + const result = shortcut.handler(event); + // If handler returns false, prevent default and stop propagation + if (result === false) { + event.preventDefault(); + return false; + } + } + } + // Allow event to propagate normally + return true; + }; +} + +/** + * Format shortcut for display (e.g., "Cmd+K" or "Ctrl+Shift+P") + */ +export function formatShortcut(shortcut: KeyboardShortcut): string { + const modifiers = shortcut.modifiers || []; + const parts: string[] = []; + + // Use platform-specific display names + const isMac = navigator.platform.toLowerCase().includes("mac"); + + for (const mod of modifiers) { + switch (mod) { + case "meta": + parts.push(isMac ? "Cmd" : "Win"); + break; + case "ctrl": + parts.push("Ctrl"); + break; + case "alt": + parts.push(isMac ? "Opt" : "Alt"); + break; + case "shift": + parts.push("Shift"); + break; + } + } + + parts.push(shortcut.key.toUpperCase()); + + return parts.join("+"); +} diff --git a/apps/desktop/src/renderer/lib/shortcuts.ts b/apps/desktop/src/renderer/lib/shortcuts.ts new file mode 100644 index 00000000000..9dcd73eb6d3 --- /dev/null +++ b/apps/desktop/src/renderer/lib/shortcuts.ts @@ -0,0 +1,199 @@ +/** + * Arc-style keyboard shortcuts for Superset + */ + +import type { + KeyboardShortcut, + KeyboardShortcutGroup, +} from "./keyboard-shortcuts"; + +export interface ShortcutHandlers { + // Workspace management + switchToPrevWorkspace: () => void; + switchToNextWorkspace: () => void; + toggleSidebar: () => void; + createSplitView: () => void; + createVerticalSplit: () => void; + + // Tab management + switchToPrevTab: () => void; + switchToNextTab: () => void; + newTab: () => void; + closeTab: () => void; + reopenClosedTab: () => void; + jumpToTab: (index: number) => void; + + // Terminal specific + clearTerminal: () => void; +} + +export function createWorkspaceShortcuts( + handlers: Pick< + ShortcutHandlers, + | "switchToPrevWorkspace" + | "switchToNextWorkspace" + | "toggleSidebar" + | "createSplitView" + | "createVerticalSplit" + >, +): KeyboardShortcutGroup { + return { + name: "Workspace Management", + shortcuts: [ + { + key: "ArrowLeft", + modifiers: ["meta", "alt"], + description: "Switch to previous workspace", + handler: (event) => { + event.preventDefault(); + handlers.switchToPrevWorkspace(); + return false; + }, + }, + { + key: "ArrowRight", + modifiers: ["meta", "alt"], + description: "Switch to next workspace", + handler: (event) => { + event.preventDefault(); + handlers.switchToNextWorkspace(); + return false; + }, + }, + { + key: "s", + modifiers: ["meta"], + description: "Toggle sidebar visibility", + handler: (event) => { + event.preventDefault(); + handlers.toggleSidebar(); + return false; + }, + }, + { + key: "d", + modifiers: ["meta"], + description: "Create split view (horizontal)", + handler: (event) => { + event.preventDefault(); + handlers.createSplitView(); + return false; + }, + }, + { + key: "d", + modifiers: ["meta", "shift"], + description: "Create split view (vertical)", + handler: (event) => { + event.preventDefault(); + handlers.createVerticalSplit(); + return false; + }, + }, + ], + }; +} + +export function createTabShortcuts( + handlers: Pick< + ShortcutHandlers, + | "switchToPrevTab" + | "switchToNextTab" + | "newTab" + | "closeTab" + | "reopenClosedTab" + | "jumpToTab" + >, +): KeyboardShortcutGroup { + const shortcuts: KeyboardShortcut[] = [ + { + key: "ArrowUp", + modifiers: ["meta", "alt"], + description: "Switch to previous tab", + handler: (event) => { + event.preventDefault(); + handlers.switchToPrevTab(); + return false; + }, + }, + { + key: "ArrowDown", + modifiers: ["meta", "alt"], + description: "Switch to next tab", + handler: (event) => { + event.preventDefault(); + handlers.switchToNextTab(); + return false; + }, + }, + { + key: "t", + modifiers: ["meta"], + description: "Create new tab", + handler: (event) => { + event.preventDefault(); + handlers.newTab(); + return false; + }, + }, + { + key: "w", + modifiers: ["meta"], + description: "Close current tab", + handler: (event) => { + event.preventDefault(); + handlers.closeTab(); + return false; + }, + }, + { + key: "t", + modifiers: ["meta", "shift"], + description: "Reopen closed tab", + handler: (event) => { + event.preventDefault(); + handlers.reopenClosedTab(); + return false; + }, + }, + ]; + + // Add Cmd+1-9 shortcuts for jumping to tabs + for (let i = 1; i <= 9; i++) { + shortcuts.push({ + key: i.toString(), + modifiers: ["meta"], + description: `Jump to tab ${i}`, + handler: (event) => { + event.preventDefault(); + handlers.jumpToTab(i); + return false; + }, + }); + } + + return { + name: "Tab Management", + shortcuts, + }; +} + +export function createTerminalShortcuts( + handlers: Pick, +): KeyboardShortcutGroup { + return { + name: "Terminal", + shortcuts: [ + { + key: "k", + modifiers: ["meta"], + description: "Clear terminal (scrollback + screen)", + handler: (event) => { + event.preventDefault(); + handlers.clearTerminal(); + return false; + }, + }, + ], + }; +} diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 022b116d93a..db7a7b26640 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -17,7 +17,8 @@ import { ResizablePanel, ResizablePanelGroup, } from "@superset/ui/resizable"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { ImperativePanelHandle } from "react-resizable-panels"; import type { MosaicNode, Tab, TabType, Workspace } from "shared/types"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -26,6 +27,11 @@ import TabGroup from "./components/MainContent/TabGroup"; import { PlaceholderState } from "./components/PlaceholderState"; import { Sidebar } from "./components/Sidebar"; import { TopBar } from "./components/TopBar"; +import { createShortcutHandler } from "../../lib/keyboard-shortcuts"; +import { + createWorkspaceShortcuts, + createTabShortcuts, +} from "../../lib/shortcuts"; // Droppable wrapper for main content area function DroppableMainContent({ @@ -63,6 +69,8 @@ function DroppableMainContent({ export function MainScreen() { const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [showSidebarOverlay, setShowSidebarOverlay] = useState(false); + const sidebarPanelRef = useRef(null); const [workspaces, setWorkspaces] = useState(null); const [currentWorkspace, setCurrentWorkspace] = useState( null, @@ -380,6 +388,138 @@ export function MainScreen() { }; }, []); + // Listen for terminal exit events and auto-close the tab + useEffect(() => { + const handleTerminalExit = async (data: { + id: string; + exitCode: number; + }) => { + console.log( + `[MainScreen] Terminal ${data.id} exited with code ${data.exitCode}`, + ); + + if (!currentWorkspace || !selectedWorktreeId) return; + + // Find which tab contains this terminal + const worktree = currentWorkspace.worktrees.find( + (wt) => wt.id === selectedWorktreeId, + ); + if (!worktree) return; + + // Check if the exited terminal is the currently selected tab + const isCurrentTab = selectedTabId === data.id; + + // Find the tab to determine its context (top-level or in group) + const tabResult = findTabRecursive(worktree.tabs, data.id); + if (!tabResult) return; + + const parentGroup = tabResult.parent; + const isInGroup = !!parentGroup; + + // Get the tabs array (either from group or top-level) + const tabs = isInGroup ? parentGroup?.tabs || [] : worktree.tabs; + const currentIndex = tabs.findIndex((t) => t.id === data.id); + + // Update mosaic tree if in a group + if (isInGroup && parentGroup && parentGroup.mosaicTree) { + const updatedMosaicTree = removeTabFromMosaicTree( + parentGroup.mosaicTree as MosaicNode, + data.id, + ); + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: parentGroup.id, + mosaicTree: updatedMosaicTree, + }); + } + + // Delete the tab + const result = await window.ipcRenderer.invoke("tab-delete", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: data.id, + }); + + if (!result.success) { + console.error("Failed to close exited terminal tab:", result.error); + return; + } + + // Temporarily clear selection to force unmount if it's the current tab + const savedTabId = selectedTabId; + const savedWorktreeId = selectedWorktreeId; + if (isCurrentTab) { + setSelectedTabId(null); + } + + // Refresh workspace + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + await loadAllWorkspaces(); + + // Wait for next tick to ensure state updates + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Only select adjacent tab if the exited terminal was the current one + if (isCurrentTab) { + const updatedWorktree = refreshedWorkspace.worktrees.find( + (wt) => wt.id === savedWorktreeId, + ); + + if (updatedWorktree) { + if (isInGroup && parentGroup) { + // Select adjacent tab within the group + const updatedGroupTab = findTabById( + updatedWorktree.tabs, + parentGroup.id, + ); + if ( + updatedGroupTab && + updatedGroupTab.tabs && + updatedGroupTab.tabs.length > 0 + ) { + const newIndex = Math.min( + currentIndex, + updatedGroupTab.tabs.length - 1, + ); + handleTabSelect( + savedWorktreeId, + updatedGroupTab.tabs[newIndex].id, + ); + } else { + setSelectedTabId(null); + } + } else if (updatedWorktree.tabs.length > 0) { + // Select adjacent top-level tab + const newIndex = Math.min( + currentIndex, + updatedWorktree.tabs.length - 1, + ); + handleTabSelect( + savedWorktreeId, + updatedWorktree.tabs[newIndex].id, + ); + } else { + setSelectedTabId(null); + } + } + } + } + }; + + window.ipcRenderer.on("terminal-exited", handleTerminalExit); + return () => { + window.ipcRenderer.off("terminal-exited", handleTerminalExit); + }; + }, [currentWorkspace, selectedWorktreeId, selectedTabId]); + // Helper: recursively find a tab by ID const findTabById = (tabs: Tab[], tabId: string): Tab | null => { for (const tab of tabs) { @@ -392,6 +532,41 @@ export function MainScreen() { return null; }; + // Helper: Remove tab ID from mosaic tree + const removeTabFromMosaicTree = ( + tree: MosaicNode, + tabId: string, + ): MosaicNode | null => { + if (typeof tree === "string") { + // If this is the tab to remove, return null + return tree === tabId ? null : tree; + } + + // Recursively remove from branches + const newFirst = removeTabFromMosaicTree(tree.first, tabId); + const newSecond = removeTabFromMosaicTree(tree.second, tabId); + + // If both branches are gone, return null + if (!newFirst && !newSecond) { + return null; + } + + // If one branch is gone, return the other + if (!newFirst) { + return newSecond; + } + if (!newSecond) { + return newFirst; + } + + // Both branches exist, keep the structure + return { + ...tree, + first: newFirst, + second: newSecond, + }; + }; + // Helper: Add tab ID to mosaic tree const addTabToMosaicTree = ( tree: MosaicNode | null | undefined, @@ -887,6 +1062,515 @@ export function MainScreen() { ? findTabById(selectedWorktree.tabs, activeId) : null; + // Set up keyboard shortcuts + useEffect(() => { + const workspaceShortcuts = createWorkspaceShortcuts({ + switchToPrevWorkspace: () => { + if (!workspaces || !currentWorkspace) return; + const currentIndex = workspaces.findIndex( + (ws) => ws.id === currentWorkspace.id, + ); + if (currentIndex > 0) { + handleWorkspaceSelect(workspaces[currentIndex - 1].id); + } + }, + switchToNextWorkspace: () => { + if (!workspaces || !currentWorkspace) return; + const currentIndex = workspaces.findIndex( + (ws) => ws.id === currentWorkspace.id, + ); + if (currentIndex < workspaces.length - 1) { + handleWorkspaceSelect(workspaces[currentIndex + 1].id); + } + }, + toggleSidebar: () => { + const panel = sidebarPanelRef.current; + if (!panel) return; + + if (panel.isCollapsed()) { + panel.expand(); + setIsSidebarOpen(true); + } else { + panel.collapse(); + setIsSidebarOpen(false); + } + }, + createSplitView: async () => { + // Create horizontal split + if (!currentWorkspace || !selectedWorktreeId || !selectedTab) return; + + // If we're inside a group (parentGroupTab exists), add to that group + if (parentGroupTab) { + const newTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (!newTab) return; + + // Move into the parent group + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + sourceParentTabId: undefined, + targetParentTabId: parentGroupTab.id, + targetIndex: parentGroupTab.tabs?.length || 0, + }); + + // Update mosaic tree (horizontal split) - add to existing group's mosaic + const updatedMosaicTree = addTabToMosaicTree( + parentGroupTab.mosaicTree, + newTab.id, + ); + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: parentGroupTab.id, + mosaicTree: updatedMosaicTree, + }); + + // Select the newly created terminal + setSelectedTabId(newTab.id); + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + }); + } else { + // Create new group with horizontal split + const newTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (!newTab) return; + + const groupTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Tab Group", + "group", + ); + if (!groupTab) return; + + // Move both tabs into group + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: selectedTab.id, + sourceParentTabId: undefined, + targetParentTabId: groupTab.id, + targetIndex: 0, + }); + + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + sourceParentTabId: undefined, + targetParentTabId: groupTab.id, + targetIndex: 1, + }); + + // Create horizontal mosaic tree + const mosaicTree: MosaicNode = { + direction: "row", + first: selectedTab.id, + second: newTab.id, + splitPercentage: 50, + }; + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: groupTab.id, + mosaicTree, + }); + + // Select the newly created terminal (not the group) + setSelectedTabId(newTab.id); + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + }); + } + + // Refresh workspace + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + // Also refresh workspaces list for sidebar + await loadAllWorkspaces(); + } + }, + createVerticalSplit: async () => { + // Create vertical split + if (!currentWorkspace || !selectedWorktreeId || !selectedTab) return; + + // If we're inside a group (parentGroupTab exists), add to that group + if (parentGroupTab) { + const newTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (!newTab) return; + + // Move into the parent group + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + sourceParentTabId: undefined, + targetParentTabId: parentGroupTab.id, + targetIndex: parentGroupTab.tabs?.length || 0, + }); + + const first = parentGroupTab.mosaicTree; + + if (!first) { + console.error( + "Failed to create vertical split: parentGroupTab.mosaicTree is undefined" + ); + return; + } + + // Update mosaic tree with column direction for vertical split + const updatedMosaicTree: MosaicNode = { + direction: "column", + first, + second: newTab.id, + splitPercentage: 50, + } satisfies MosaicNode; + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: parentGroupTab.id, + mosaicTree: updatedMosaicTree, + }); + + // Select the newly created terminal + setSelectedTabId(newTab.id); + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + }); + } else { + // Create new group with vertical split + const newTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (!newTab) return; + + const groupTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Tab Group", + "group", + ); + if (!groupTab) return; + + // Move both tabs into group + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: selectedTab.id, + sourceParentTabId: undefined, + targetParentTabId: groupTab.id, + targetIndex: 0, + }); + + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + sourceParentTabId: undefined, + targetParentTabId: groupTab.id, + targetIndex: 1, + }); + + // Create vertical mosaic tree + const mosaicTree: MosaicNode = { + direction: "column", + first: selectedTab.id, + second: newTab.id, + splitPercentage: 50, + }; + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: groupTab.id, + mosaicTree, + }); + + // Select the newly created terminal (not the group) + setSelectedTabId(newTab.id); + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + }); + } + + // Refresh workspace + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + // Also refresh workspaces list for sidebar + await loadAllWorkspaces(); + } + }, + }); + + const tabShortcuts = createTabShortcuts({ + switchToPrevTab: () => { + if (!selectedWorktree || !selectedTabId) return; + + // If we're inside a group tab, navigate between group's children + if (parentGroupTab && parentGroupTab.tabs) { + const tabs = parentGroupTab.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex > 0) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex - 1].id); + } + } else { + // Navigate between top-level tabs + const tabs = selectedWorktree.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex > 0) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex - 1].id); + } + } + }, + switchToNextTab: () => { + if (!selectedWorktree || !selectedTabId) return; + + // If we're inside a group tab, navigate between group's children + if (parentGroupTab && parentGroupTab.tabs) { + const tabs = parentGroupTab.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex < tabs.length - 1) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex + 1].id); + } + } else { + // Navigate between top-level tabs + const tabs = selectedWorktree.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex < tabs.length - 1) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex + 1].id); + } + } + }, + newTab: async () => { + if (!currentWorkspace || !selectedWorktreeId) return; + + try { + const result = await window.ipcRenderer.invoke("tab-create", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + name: "New Terminal", + type: "terminal", + }); + + if (result.success && result.tab) { + const newTabId = result.tab.id; + + // Select the new tab first (matches sidebar button behavior) + handleTabSelect(selectedWorktreeId, newTabId); + + // Then refresh workspace to get updated data + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + // Also refresh workspaces list for sidebar + await loadAllWorkspaces(); + } + } else { + console.error("Failed to create tab:", result.error); + } + } catch (error) { + console.error("Error creating new tab:", error); + } + }, + closeTab: async () => { + if (!currentWorkspace || !selectedWorktreeId || !selectedTabId) return; + + // Check if we're inside a group tab + const isInGroup = !!parentGroupTab; + const tabs = isInGroup + ? parentGroupTab?.tabs || [] + : selectedWorktree?.tabs || []; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + const tabToClose = selectedTabId; + + // Delete the tab first + const result = await window.ipcRenderer.invoke("tab-delete", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: tabToClose, + }); + + if (!result.success) { + console.error("Failed to close tab:", result.error); + return; + } + + // Then update mosaic tree if in a group (after deletion) + if (isInGroup && parentGroupTab && parentGroupTab.mosaicTree) { + const updatedMosaicTree = removeTabFromMosaicTree( + parentGroupTab.mosaicTree as MosaicNode, + tabToClose, + ); + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: parentGroupTab.id, + mosaicTree: updatedMosaicTree, + }); + } + + // Refresh workspace to get updated tab list + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + + if (refreshedWorkspace) { + // Force update workspaces list first for sidebar + await loadAllWorkspaces(); + + // Update workspace state with new object reference + setCurrentWorkspace(refreshedWorkspace); + + // Find the worktree and updated parent group if applicable + const updatedWorktree = refreshedWorkspace.worktrees.find( + (wt) => wt.id === selectedWorktreeId, + ); + + if (updatedWorktree) { + // Re-find the parent group from the refreshed workspace + const wasInGroup = isInGroup; + const oldParentId = parentGroupTab?.id; + + if (wasInGroup && oldParentId) { + const updatedGroupTab = findTabById( + updatedWorktree.tabs, + oldParentId, + ); + if ( + updatedGroupTab && + updatedGroupTab.tabs && + updatedGroupTab.tabs.length > 0 + ) { + // Select adjacent tab within the group + const newIndex = Math.min( + currentIndex, + updatedGroupTab.tabs.length - 1, + ); + handleTabSelect( + selectedWorktreeId, + updatedGroupTab.tabs[newIndex].id, + ); + } else { + // Group is now empty, clear selection + setSelectedTabId(null); + } + } else if (updatedWorktree.tabs.length > 0) { + // Top-level tab - select adjacent top-level tab + const newIndex = Math.min( + currentIndex, + updatedWorktree.tabs.length - 1, + ); + handleTabSelect( + selectedWorktreeId, + updatedWorktree.tabs[newIndex].id, + ); + } else { + // No tabs left, clear selection + setSelectedTabId(null); + } + } + } + }, + reopenClosedTab: () => { + // TODO: implement reopen closed tab + console.log("Reopen closed tab"); + }, + jumpToTab: (index: number) => { + if (!selectedWorktree) return; + + // Flatten tabs: expand group children to number all actual terminals + const flattenTabs = (tabs: Tab[]): Tab[] => { + const result: Tab[] = []; + for (const tab of tabs) { + if (tab.type === "group" && tab.tabs && tab.tabs.length > 0) { + // Add all children of the group + result.push(...tab.tabs); + } else { + // Add non-group tabs + result.push(tab); + } + } + return result; + }; + + const flatTabs = flattenTabs(selectedWorktree.tabs); + if (index > 0 && index <= flatTabs.length) { + handleTabSelect(selectedWorktree.id, flatTabs[index - 1].id); + } + }, + }); + + const handleKeyDown = (event: KeyboardEvent) => { + // Try workspace shortcuts first + const workspaceHandler = createShortcutHandler( + workspaceShortcuts.shortcuts, + ); + if (!workspaceHandler(event)) { + return; + } + + // Then try tab shortcuts + const tabHandler = createShortcutHandler(tabShortcuts.shortcuts); + tabHandler(event); + }; + + // Use capture phase to intercept events before they reach terminal + window.addEventListener("keydown", handleKeyDown, true); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [ + workspaces, + currentWorkspace, + selectedWorktree, + selectedWorktreeId, + selectedTabId, + ]); + return ( + {/* Hover trigger area when sidebar is hidden */} + {!isSidebarOpen && ( +
setShowSidebarOverlay(true)} + /> + )} + + {/* Sidebar overlay when hidden and hovering */} + {!isSidebarOpen && showSidebarOverlay && workspaces && ( +
setShowSidebarOverlay(false)} + > +
+ { + setShowSidebarOverlay(false); + }} + isDragging={!!activeId} + /> +
+
+ )} + {/* App Frame - continuous border + sidebar + topbar */} setIsSidebarOpen(false)} + onExpand={() => setIsSidebarOpen(true)} > {isSidebarOpen && workspaces && ( ); @@ -132,6 +133,7 @@ interface TerminalTabContentProps { workspaceId?: string; worktreeId?: string; groupTabId: string; // ID of the parent group tab + selectedTabId?: string; // Currently selected tab ID onFocus: () => void; } @@ -141,10 +143,12 @@ function TerminalTabContent({ workspaceId, worktreeId, groupTabId, + selectedTabId, onFocus, }: TerminalTabContentProps) { const terminalId = tab.id; const terminalCreatedRef = useRef(false); + const isSelected = selectedTabId === tab.id; // Terminal creation and lifecycle useEffect(() => { @@ -227,7 +231,12 @@ function TerminalTabContent({ return (
- +
); } diff --git a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx index 3445d5fb639..5625d3b7386 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx @@ -4,6 +4,8 @@ import "@xterm/xterm/css/xterm.css"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; +import { createShortcutHandler } from "../../../../lib/keyboard-shortcuts"; +import { createTerminalShortcuts } from "../../../../lib/shortcuts"; // WebglAddon disabled due to cursor positioning issues with autocomplete // import { WebglAddon } from "@xterm/addon-webgl"; @@ -19,6 +21,7 @@ interface TerminalProps { terminalId?: string | null; hidden?: boolean; className?: string; + isSelected?: boolean; onFocus?: () => void; } @@ -79,6 +82,7 @@ export default function TerminalComponent({ terminalId, hidden = false, className = "", + isSelected = true, onFocus, }: TerminalProps) { const terminalRef = useRef(null); @@ -93,6 +97,16 @@ export default function TerminalComponent({ onFocusRef.current = onFocus; }, [onFocus]); + // // Auto-focus terminal when selected (new tab or switched tab) + // useEffect(() => { + // if (terminal && terminalId && isSelected) { + // // Small delay to ensure terminal is fully mounted + // setTimeout(() => { + // terminal?.textarea?.focus(); + // }, 50); + // } + // }, [terminal, terminalId, isSelected]); + useEffect(() => { if (terminal) { terminal.options.theme = @@ -149,12 +163,9 @@ export default function TerminalComponent({ let isResizing = false; let writeQueue: string[] = []; - // Add iTerm2-like keyboard shortcuts - term.attachCustomKeyEventHandler((event: KeyboardEvent) => { - // Cmd+K (Mac) or Ctrl+K (Win/Linux): Clear terminal like iTerm2 - // This clears both the scrollback buffer and sends clear to the shell - if (event.metaKey && event.key === "k") { - event.preventDefault(); + // Set up keyboard shortcuts + const terminalShortcuts = createTerminalShortcuts({ + clearTerminal: () => { // Clear the xterm buffer (removes scrollback) term.clear(); // Also send clear command to shell to reset shell state @@ -164,12 +175,12 @@ export default function TerminalComponent({ data: "\x0c", // Form feed (Ctrl+L) - clears screen in most shells }); } - return false; // Prevent default terminal handling - } - // Allow all other keys to be processed normally - return true; + }, }); + const handleShortcut = createShortcutHandler(terminalShortcuts.shortcuts); + term.attachCustomKeyEventHandler(handleShortcut); + // Load addons // 1. WebLinks - Makes URLs clickable and open in default browser const webLinksAddon = new WebLinksAddon((event, uri) => {