- Updated README to specify CLI runtime capabilities for headless environments. - Introduced a comprehensive implementation plan for the diff view feature, detailing phases, required packages, types, IPC channels, backend services, and UI components. - Added new documentation on competitors in AI orchestration platforms and research on local image storage and markdown rendering pipeline. - Included a workflow editor research document to evaluate visual libraries for node-based workflow orchestration.
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
/**
|
|
* 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<string, unknown> {
|
|
const synced = syncFocusedPaneState(layout);
|
|
return {
|
|
paneLayout: layout,
|
|
openTabs: synced.openTabs,
|
|
activeTabId: synced.activeTabId,
|
|
selectedTabIds: synced.selectedTabIds,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Slice Creator
|
|
// =============================================================================
|
|
|
|
export const createPaneSlice: StateCreator<AppState, [], [], PaneSlice> = (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);
|
|
},
|
|
});
|