/** * Pane slice - manages multi-pane split layout state and actions. * Each pane has its own tab bar, active tab, and selected tabs. */ import { MAX_PANES } from '@renderer/types/panes'; import { createEmptyPane, findPane, findPaneByTabId, getAllTabs, insertPane, removePane, syncFocusedPaneState, updatePane, } from '../utils/paneHelpers'; import type { AppState } from '../types'; import type { PaneLayout } from '@renderer/types/panes'; import type { Tab } from '@renderer/types/tabs'; import type { StateCreator } from 'zustand'; // ============================================================================= // Slice Interface // ============================================================================= export interface PaneSlice { // State paneLayout: PaneLayout; // Pane lifecycle focusPane: (paneId: string) => void; splitPane: (sourcePaneId: string, tabId: string, direction: 'left' | 'right') => void; closePane: (paneId: string) => void; // Tab movement moveTabToPane: ( tabId: string, sourcePaneId: string, targetPaneId: string, insertIndex?: number ) => void; moveTabToNewPane: ( tabId: string, sourcePaneId: string, adjacentPaneId: string, direction: 'left' | 'right' ) => void; reorderTabInPane: (paneId: string, fromIndex: number, toIndex: number) => void; // Resize resizePanes: (paneId: string, newWidthFraction: number) => void; // Queries getPaneForTab: (tabId: string) => string | null; getAllPaneTabs: () => Tab[]; } // ============================================================================= // Helpers // ============================================================================= /** * Sync root-level openTabs/activeTabId/selectedTabIds from the focused pane. * This maintains backward compatibility for consumers that read root-level state. */ function syncRootState(layout: PaneLayout): Record { const synced = syncFocusedPaneState(layout); return { paneLayout: layout, openTabs: synced.openTabs, activeTabId: synced.activeTabId, selectedTabIds: synced.selectedTabIds, }; } // ============================================================================= // Slice Creator // ============================================================================= export const createPaneSlice: StateCreator = (set, get) => ({ // Initial state: single pane (populated by tabSlice init or first openTab) paneLayout: { panes: [ { id: 'pane-default', tabs: [], activeTabId: null, selectedTabIds: [], widthFraction: 1, }, ], focusedPaneId: 'pane-default', }, focusPane: (paneId: string) => { const state = get(); const { paneLayout } = state; if (paneLayout.focusedPaneId === paneId) return; const pane = findPane(paneLayout, paneId); if (!pane) return; const newLayout: PaneLayout = { ...paneLayout, focusedPaneId: paneId }; set(syncRootState(newLayout)); // Trigger sidebar sync for the focused pane's active tab if (pane.activeTabId) { get().setActiveTab(pane.activeTabId); } }, splitPane: (sourcePaneId: string, tabId: string, direction: 'left' | 'right') => { const state = get(); const { paneLayout } = state; if (paneLayout.panes.length >= MAX_PANES) return; const sourcePane = findPane(paneLayout, sourcePaneId); if (!sourcePane) return; const tab = sourcePane.tabs.find((t) => t.id === tabId); if (!tab) return; // Remove tab from source pane const newSourceTabs = sourcePane.tabs.filter((t) => t.id !== tabId); let newSourceActiveTabId = sourcePane.activeTabId; if (sourcePane.activeTabId === tabId) { // Focus adjacent tab in source const oldIndex = sourcePane.tabs.findIndex((t) => t.id === tabId); newSourceActiveTabId = newSourceTabs[oldIndex]?.id ?? newSourceTabs[oldIndex - 1]?.id ?? null; } const updatedSource = { ...sourcePane, tabs: newSourceTabs, activeTabId: newSourceActiveTabId, selectedTabIds: sourcePane.selectedTabIds.filter((id) => id !== tabId), }; // Create new pane with the tab const newPaneId = crypto.randomUUID(); const newPane = { ...createEmptyPane(newPaneId), tabs: [tab], activeTabId: tab.id, }; // Update layout let newLayout = updatePane(paneLayout, updatedSource); // If source pane is now empty, remove it if (newSourceTabs.length === 0 && paneLayout.panes.length > 1) { newLayout = removePane(newLayout, sourcePaneId); } newLayout = insertPane( newLayout, updatedSource.id !== sourcePaneId ? paneLayout.panes[0].id : sourcePaneId, newPane, direction ); newLayout = { ...newLayout, focusedPaneId: newPaneId }; set(syncRootState(newLayout)); // Sync sidebar for the new pane's active tab if (tab.type === 'session') { get().setActiveTab(tab.id); } }, closePane: (paneId: string) => { const state = get(); const { paneLayout } = state; if (paneLayout.panes.length <= 1) return; // Can't close the last pane const pane = findPane(paneLayout, paneId); if (!pane) return; // Cleanup tab UI state and session data for all tabs in the pane for (const tab of pane.tabs) { state.cleanupTabUIState(tab.id); state.cleanupTabSessionData(tab.id); } const newLayout = removePane(paneLayout, paneId); set(syncRootState(newLayout)); // Sync sidebar for the newly focused pane const focusedPane = findPane(newLayout, newLayout.focusedPaneId); if (focusedPane?.activeTabId) { get().setActiveTab(focusedPane.activeTabId); } }, moveTabToPane: ( tabId: string, sourcePaneId: string, targetPaneId: string, insertIndex?: number ) => { const state = get(); const { paneLayout } = state; if (sourcePaneId === targetPaneId) return; const sourcePane = findPane(paneLayout, sourcePaneId); const targetPane = findPane(paneLayout, targetPaneId); if (!sourcePane || !targetPane) return; const tab = sourcePane.tabs.find((t) => t.id === tabId); if (!tab) return; // Remove from source const newSourceTabs = sourcePane.tabs.filter((t) => t.id !== tabId); let newSourceActiveTabId = sourcePane.activeTabId; if (sourcePane.activeTabId === tabId) { const oldIndex = sourcePane.tabs.findIndex((t) => t.id === tabId); newSourceActiveTabId = newSourceTabs[oldIndex]?.id ?? newSourceTabs[oldIndex - 1]?.id ?? null; } // Add to target at insertion index const newTargetTabs = [...targetPane.tabs]; if (insertIndex !== undefined && insertIndex >= 0 && insertIndex <= newTargetTabs.length) { newTargetTabs.splice(insertIndex, 0, tab); } else { newTargetTabs.push(tab); } let newLayout = updatePane(paneLayout, { ...sourcePane, tabs: newSourceTabs, activeTabId: newSourceActiveTabId, selectedTabIds: sourcePane.selectedTabIds.filter((id) => id !== tabId), }); newLayout = updatePane(newLayout, { ...targetPane, tabs: newTargetTabs, activeTabId: tab.id, }); // Auto-close source pane if it's empty and not the sole pane if (newSourceTabs.length === 0 && newLayout.panes.length > 1) { newLayout = removePane(newLayout, sourcePaneId); } // Focus the target pane newLayout = { ...newLayout, focusedPaneId: targetPaneId }; set(syncRootState(newLayout)); }, moveTabToNewPane: ( tabId: string, sourcePaneId: string, adjacentPaneId: string, direction: 'left' | 'right' ) => { const state = get(); const { paneLayout } = state; if (paneLayout.panes.length >= MAX_PANES) return; const sourcePane = findPane(paneLayout, sourcePaneId); if (!sourcePane) return; const tab = sourcePane.tabs.find((t) => t.id === tabId); if (!tab) return; // Remove from source const newSourceTabs = sourcePane.tabs.filter((t) => t.id !== tabId); let newSourceActiveTabId = sourcePane.activeTabId; if (sourcePane.activeTabId === tabId) { const oldIndex = sourcePane.tabs.findIndex((t) => t.id === tabId); newSourceActiveTabId = newSourceTabs[oldIndex]?.id ?? newSourceTabs[oldIndex - 1]?.id ?? null; } const newPaneId = crypto.randomUUID(); const newPane = { ...createEmptyPane(newPaneId), tabs: [tab], activeTabId: tab.id, }; let newLayout = updatePane(paneLayout, { ...sourcePane, tabs: newSourceTabs, activeTabId: newSourceActiveTabId, selectedTabIds: sourcePane.selectedTabIds.filter((id) => id !== tabId), }); // Auto-close source pane if it's empty and not the sole pane if (newSourceTabs.length === 0 && newLayout.panes.length > 1) { newLayout = removePane(newLayout, sourcePaneId); } newLayout = insertPane(newLayout, adjacentPaneId, newPane, direction); newLayout = { ...newLayout, focusedPaneId: newPaneId }; set(syncRootState(newLayout)); }, reorderTabInPane: (paneId: string, fromIndex: number, toIndex: number) => { const { paneLayout } = get(); const pane = findPane(paneLayout, paneId); if (!pane) return; if (fromIndex < 0 || fromIndex >= pane.tabs.length) return; if (toIndex < 0 || toIndex >= pane.tabs.length) return; if (fromIndex === toIndex) return; const newTabs = [...pane.tabs]; const [moved] = newTabs.splice(fromIndex, 1); newTabs.splice(toIndex, 0, moved); const newLayout = updatePane(paneLayout, { ...pane, tabs: newTabs }); set(syncRootState(newLayout)); }, resizePanes: (paneId: string, newWidthFraction: number) => { const { paneLayout } = get(); const paneIndex = paneLayout.panes.findIndex((p) => p.id === paneId); if (paneIndex === -1 || paneIndex >= paneLayout.panes.length - 1) return; const MIN_FRACTION = 0.1; const clamped = Math.max( MIN_FRACTION, Math.min(1 - MIN_FRACTION * (paneLayout.panes.length - 1), newWidthFraction) ); const currentPane = paneLayout.panes[paneIndex]; const nextPane = paneLayout.panes[paneIndex + 1]; const combinedWidth = currentPane.widthFraction + nextPane.widthFraction; const nextWidth = combinedWidth - clamped; if (nextWidth < MIN_FRACTION) return; const newPanes = paneLayout.panes.map((p, i) => { if (i === paneIndex) return { ...p, widthFraction: clamped }; if (i === paneIndex + 1) return { ...p, widthFraction: nextWidth }; return p; }); set({ paneLayout: { ...paneLayout, panes: newPanes } }); }, getPaneForTab: (tabId: string) => { const pane = findPaneByTabId(get().paneLayout, tabId); return pane?.id ?? null; }, getAllPaneTabs: () => { return getAllTabs(get().paneLayout); }, });