diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index e90d2e34080..fe57e03ec1e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; import { HOTKEYS } from "shared/hotkeys"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; @@ -16,6 +17,7 @@ export function WorkspaceView() { const addTab = useTabsStore((s) => s.addTab); const setActiveTab = useTabsStore((s) => s.setActiveTab); const removePane = useTabsStore((s) => s.removePane); + const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const tabs = useMemo( () => @@ -29,6 +31,12 @@ export function WorkspaceView() { ? activeTabIds[activeWorkspaceId] : null; + // Get the active tab object for layout access + const activeTab = useMemo( + () => (activeTabId ? tabs.find((t) => t.id === activeTabId) : null), + [activeTabId, tabs], + ); + // Get focused pane ID for the active tab const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; @@ -63,6 +71,23 @@ export function WorkspaceView() { } }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); + // Switch between panes within a tab (⌘+⌥+Left/Right) + useHotkeys(HOTKEYS.PREV_PANE.keys, () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); + if (prevPaneId) { + setFocusedPane(activeTabId, prevPaneId); + } + }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); + + useHotkeys(HOTKEYS.NEXT_PANE.keys, () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); + if (nextPaneId) { + setFocusedPane(activeTabId, nextPaneId); + } + }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); + // Open in last used app shortcut const { data: lastUsedApp = "cursor" } = trpc.settings.getLastUsedApp.useQuery(); diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index dcea03ce1ce..078203d3122 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -17,7 +17,25 @@ export const getTabDisplayName = (tab: Tab): string => { }; /** - * Extracts all pane IDs from a mosaic layout tree + * Extracts all pane IDs from a mosaic layout tree in visual navigation order: + * left-to-right, top-to-bottom. + * + * For react-mosaic layouts: + * - direction: "row" = horizontal split (first is left, second is right) + * - direction: "column" = vertical split (first is top, second is bottom) + * + * This traversal visits `first` before `second` at each node, which produces + * left-to-right ordering for horizontal splits and top-to-bottom for vertical splits. + * + * Example layout: + * ``` + * ┌───────┬───────┐ + * │ A │ B │ (row split: first=A, second=B) + * ├───────┼───────┤ + * │ C │ D │ (row split: first=C, second=D) + * └───────┴───────┘ + * ``` + * If the top row is `first` in a column split, order would be: [A, B, C, D] */ export const extractPaneIdsFromLayout = ( layout: MosaicNode, @@ -32,6 +50,9 @@ export const extractPaneIdsFromLayout = ( ]; }; +/** Alias for extractPaneIdsFromLayout emphasizing the visual ordering contract */ +export const getPaneIdsInVisualOrder = extractPaneIdsFromLayout; + /** * Options for creating a pane with preset configuration */ @@ -208,6 +229,42 @@ export const getFirstPaneId = (layout: MosaicNode): string => { return getFirstPaneId(layout.first); }; +/** + * Gets the next pane ID in visual order (left-to-right, top-to-bottom), + * wrapping around to the first if at the end. + */ +export const getNextPaneId = ( + layout: MosaicNode, + currentPaneId: string, +): string | null => { + const paneIds = getPaneIdsInVisualOrder(layout); + if (paneIds.length <= 1) return null; + + const currentIndex = paneIds.indexOf(currentPaneId); + if (currentIndex === -1) return paneIds[0]; + + const nextIndex = (currentIndex + 1) % paneIds.length; + return paneIds[nextIndex]; +}; + +/** + * Gets the previous pane ID in visual order (right-to-left, bottom-to-top), + * wrapping around to the last if at the beginning. + */ +export const getPreviousPaneId = ( + layout: MosaicNode, + currentPaneId: string, +): string | null => { + const paneIds = getPaneIdsInVisualOrder(layout); + if (paneIds.length <= 1) return null; + + const currentIndex = paneIds.indexOf(currentPaneId); + if (currentIndex === -1) return paneIds[paneIds.length - 1]; + + const prevIndex = (currentIndex - 1 + paneIds.length) % paneIds.length; + return paneIds[prevIndex]; +}; + /** * Finds the path to a specific pane ID in a mosaic layout * Returns the path as an array of MosaicBranch ("first" | "second"), or null if not found diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index 02d7a1cbbcf..355639b6d20 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -177,6 +177,18 @@ export const HOTKEYS = { label: "Next Terminal", category: "Terminal", }), + PREV_PANE: hotkey({ + keys: "meta+alt+left", + label: "Previous Pane", + category: "Terminal", + description: "Focus the previous pane in the current tab", + }), + NEXT_PANE: hotkey({ + keys: "meta+alt+right", + label: "Next Pane", + category: "Terminal", + description: "Focus the next pane in the current tab", + }), // Window NEW_WINDOW: hotkey({