487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
/**
|
|
* Tests for the paneSlice - multi-pane split layout state management.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
|
import { MAX_PANES } from '../../../src/renderer/types/panes';
|
|
|
|
import { createTestStore } from './storeTestUtils';
|
|
|
|
import type { TestStore } from './storeTestUtils';
|
|
|
|
let store: TestStore;
|
|
|
|
beforeEach(() => {
|
|
store = createTestStore();
|
|
});
|
|
|
|
describe('paneSlice', () => {
|
|
describe('initial state', () => {
|
|
it('starts with a single default pane', () => {
|
|
const { paneLayout } = store.getState();
|
|
expect(paneLayout.panes).toHaveLength(1);
|
|
expect(paneLayout.panes[0].id).toBe('pane-default');
|
|
expect(paneLayout.panes[0].widthFraction).toBe(1);
|
|
expect(paneLayout.panes[0].tabs).toEqual([]);
|
|
expect(paneLayout.focusedPaneId).toBe('pane-default');
|
|
});
|
|
});
|
|
|
|
describe('focusPane', () => {
|
|
it('changes focusedPaneId', () => {
|
|
const state = store.getState();
|
|
// Open a tab first and split to create a second pane
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
const { paneLayout } = store.getState();
|
|
expect(paneLayout.panes).toHaveLength(2);
|
|
|
|
// New pane should be focused after split
|
|
const newPaneId = paneLayout.focusedPaneId;
|
|
expect(newPaneId).not.toBe('pane-default');
|
|
|
|
// Focus back to default pane
|
|
store.getState().focusPane('pane-default');
|
|
expect(store.getState().paneLayout.focusedPaneId).toBe('pane-default');
|
|
});
|
|
|
|
it('no-ops when already focused', () => {
|
|
const before = store.getState().paneLayout;
|
|
store.getState().focusPane('pane-default');
|
|
expect(store.getState().paneLayout).toBe(before);
|
|
});
|
|
|
|
it('no-ops for non-existent pane', () => {
|
|
const before = store.getState().paneLayout;
|
|
store.getState().focusPane('non-existent');
|
|
expect(store.getState().paneLayout).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('splitPane', () => {
|
|
it('creates a new pane with the specified tab to the right', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tabs = store.getState().paneLayout.panes[0].tabs;
|
|
expect(tabs).toHaveLength(2);
|
|
const tab1Id = tabs[0].id;
|
|
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
const { paneLayout } = store.getState();
|
|
expect(paneLayout.panes).toHaveLength(2);
|
|
|
|
// Source pane should have lost the tab
|
|
const sourcePane = paneLayout.panes.find((p) => p.id === 'pane-default');
|
|
expect(sourcePane?.tabs).toHaveLength(1);
|
|
expect(sourcePane?.tabs[0].sessionId).toBe('s2');
|
|
|
|
// New pane should have the split tab
|
|
const newPane = paneLayout.panes.find((p) => p.id !== 'pane-default');
|
|
expect(newPane?.tabs).toHaveLength(1);
|
|
expect(newPane?.tabs[0].sessionId).toBe('s1');
|
|
|
|
// New pane should be focused
|
|
expect(paneLayout.focusedPaneId).toBe(newPane?.id);
|
|
|
|
// Widths should be equal
|
|
expect(sourcePane?.widthFraction).toBeCloseTo(0.5);
|
|
expect(newPane?.widthFraction).toBeCloseTo(0.5);
|
|
});
|
|
|
|
it('creates a new pane to the left', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab2Id = store.getState().paneLayout.panes[0].tabs[1].id;
|
|
state.splitPane('pane-default', tab2Id, 'left');
|
|
|
|
const { paneLayout } = store.getState();
|
|
expect(paneLayout.panes).toHaveLength(2);
|
|
|
|
// New pane should be to the left (index 0)
|
|
const leftPane = paneLayout.panes[0];
|
|
expect(leftPane.tabs[0].sessionId).toBe('s2');
|
|
expect(leftPane.id).toBe(paneLayout.focusedPaneId);
|
|
});
|
|
|
|
it('does not exceed MAX_PANES', () => {
|
|
const state = store.getState();
|
|
// Create MAX_PANES tabs
|
|
for (let i = 0; i < MAX_PANES + 1; i++) {
|
|
state.openTab({
|
|
type: 'session',
|
|
sessionId: `s${i}`,
|
|
projectId: 'p1',
|
|
label: `Session ${i}`,
|
|
});
|
|
}
|
|
|
|
// Split until we reach MAX_PANES
|
|
for (let i = 0; i < MAX_PANES - 1; i++) {
|
|
const currentState = store.getState();
|
|
const focusedPane = currentState.paneLayout.panes.find(
|
|
(p) => p.id === currentState.paneLayout.focusedPaneId
|
|
);
|
|
if (focusedPane && focusedPane.tabs.length > 1) {
|
|
currentState.splitPane(focusedPane.id, focusedPane.tabs[0].id, 'right');
|
|
}
|
|
}
|
|
|
|
const paneCount = store.getState().paneLayout.panes.length;
|
|
expect(paneCount).toBeLessThanOrEqual(MAX_PANES);
|
|
|
|
// Attempting to split again should be no-op if at MAX_PANES
|
|
if (paneCount === MAX_PANES) {
|
|
const beforeLayout = store.getState().paneLayout;
|
|
const focusedPane = beforeLayout.panes.find((p) => p.id === beforeLayout.focusedPaneId);
|
|
if (focusedPane && focusedPane.tabs.length > 0) {
|
|
store.getState().splitPane(focusedPane.id, focusedPane.tabs[0].id, 'right');
|
|
expect(store.getState().paneLayout.panes).toHaveLength(MAX_PANES);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('closePane', () => {
|
|
it('removes a pane and redistributes width', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
const newPaneId = store.getState().paneLayout.panes.find((p) => p.id !== 'pane-default')?.id;
|
|
expect(newPaneId).toBeDefined();
|
|
|
|
store.getState().closePane(newPaneId!);
|
|
|
|
const { paneLayout } = store.getState();
|
|
expect(paneLayout.panes).toHaveLength(1);
|
|
expect(paneLayout.panes[0].widthFraction).toBe(1);
|
|
});
|
|
|
|
it('cannot close the last pane', () => {
|
|
store.getState().closePane('pane-default');
|
|
expect(store.getState().paneLayout.panes).toHaveLength(1);
|
|
});
|
|
|
|
it('shifts focus to neighbor when closing focused pane', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
// New pane is focused
|
|
const focusedId = store.getState().paneLayout.focusedPaneId;
|
|
expect(focusedId).not.toBe('pane-default');
|
|
|
|
// Close the focused pane
|
|
store.getState().closePane(focusedId);
|
|
|
|
// Focus should shift to remaining pane
|
|
expect(store.getState().paneLayout.focusedPaneId).toBe('pane-default');
|
|
});
|
|
});
|
|
|
|
describe('moveTabToPane', () => {
|
|
it('moves a tab from one pane to another', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
state.openTab({ type: 'session', sessionId: 's3', projectId: 'p1', label: 'Session 3' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
const panes = store.getState().paneLayout.panes;
|
|
const newPaneId = panes.find((p) => p.id !== 'pane-default')!.id;
|
|
|
|
// Move s2 from pane-default to the new pane
|
|
const tab2Id = panes.find((p) => p.id === 'pane-default')!.tabs[0].id;
|
|
store.getState().moveTabToPane(tab2Id, 'pane-default', newPaneId);
|
|
|
|
const updatedPanes = store.getState().paneLayout.panes;
|
|
const sourcePane = updatedPanes.find((p) => p.id === 'pane-default')!;
|
|
const targetPane = updatedPanes.find((p) => p.id === newPaneId)!;
|
|
|
|
expect(sourcePane.tabs).toHaveLength(1); // s3 left
|
|
expect(targetPane.tabs).toHaveLength(2); // s1 + s2
|
|
});
|
|
|
|
it('auto-closes source pane when last tab is moved out', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
// Now pane-default has s2, new pane has s1
|
|
const newPaneId = store.getState().paneLayout.panes.find((p) => p.id !== 'pane-default')!.id;
|
|
|
|
// Move s2 (last tab in pane-default) to new pane
|
|
const tab2Id = store.getState().paneLayout.panes.find((p) => p.id === 'pane-default')!.tabs[0]
|
|
.id;
|
|
store.getState().moveTabToPane(tab2Id, 'pane-default', newPaneId);
|
|
|
|
// pane-default should be auto-closed
|
|
const panes = store.getState().paneLayout.panes;
|
|
expect(panes).toHaveLength(1);
|
|
expect(panes[0].id).toBe(newPaneId);
|
|
expect(panes[0].tabs).toHaveLength(2);
|
|
});
|
|
|
|
it('no-ops when source and target are the same', () => {
|
|
store.getState().openTab({
|
|
type: 'session',
|
|
sessionId: 's1',
|
|
projectId: 'p1',
|
|
label: 'Session 1',
|
|
});
|
|
const tabId = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
const before = store.getState().paneLayout;
|
|
store.getState().moveTabToPane(tabId, 'pane-default', 'pane-default');
|
|
expect(store.getState().paneLayout).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('reorderTabInPane', () => {
|
|
it('reorders tabs within a pane', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
state.openTab({ type: 'session', sessionId: 's3', projectId: 'p1', label: 'Session 3' });
|
|
|
|
const tabs = store.getState().paneLayout.panes[0].tabs;
|
|
expect(tabs[0].sessionId).toBe('s1');
|
|
expect(tabs[2].sessionId).toBe('s3');
|
|
|
|
// Move first tab to last position
|
|
store.getState().reorderTabInPane('pane-default', 0, 2);
|
|
|
|
const reordered = store.getState().paneLayout.panes[0].tabs;
|
|
expect(reordered[0].sessionId).toBe('s2');
|
|
expect(reordered[1].sessionId).toBe('s3');
|
|
expect(reordered[2].sessionId).toBe('s1');
|
|
});
|
|
|
|
it('no-ops for same index', () => {
|
|
store.getState().openTab({
|
|
type: 'session',
|
|
sessionId: 's1',
|
|
projectId: 'p1',
|
|
label: 'Session 1',
|
|
});
|
|
const before = store.getState().paneLayout;
|
|
store.getState().reorderTabInPane('pane-default', 0, 0);
|
|
expect(store.getState().paneLayout).toBe(before);
|
|
});
|
|
|
|
it('no-ops for out-of-bounds index', () => {
|
|
store.getState().openTab({
|
|
type: 'session',
|
|
sessionId: 's1',
|
|
projectId: 'p1',
|
|
label: 'Session 1',
|
|
});
|
|
const before = store.getState().paneLayout;
|
|
store.getState().reorderTabInPane('pane-default', 0, 5);
|
|
expect(store.getState().paneLayout).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('resizePanes', () => {
|
|
it('adjusts width fractions of adjacent panes', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
// Resize pane-default to 60%
|
|
store.getState().resizePanes('pane-default', 0.6);
|
|
|
|
const panes = store.getState().paneLayout.panes;
|
|
const defaultPane = panes.find((p) => p.id === 'pane-default')!;
|
|
const otherPane = panes.find((p) => p.id !== 'pane-default')!;
|
|
|
|
expect(defaultPane.widthFraction).toBeCloseTo(0.6);
|
|
expect(otherPane.widthFraction).toBeCloseTo(0.4);
|
|
});
|
|
|
|
it('clamps to minimum fraction', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
// Try to make pane-default almost 100% (leaving too little for neighbor)
|
|
store.getState().resizePanes('pane-default', 0.95);
|
|
|
|
const panes = store.getState().paneLayout.panes;
|
|
for (const pane of panes) {
|
|
expect(pane.widthFraction).toBeGreaterThanOrEqual(0.1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('getPaneForTab', () => {
|
|
it('returns the pane ID containing the tab', () => {
|
|
store.getState().openTab({
|
|
type: 'session',
|
|
sessionId: 's1',
|
|
projectId: 'p1',
|
|
label: 'Session 1',
|
|
});
|
|
const tabId = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
expect(store.getState().getPaneForTab(tabId)).toBe('pane-default');
|
|
});
|
|
|
|
it('returns null for non-existent tab', () => {
|
|
expect(store.getState().getPaneForTab('non-existent')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getAllPaneTabs', () => {
|
|
it('returns all tabs across all panes', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
const allTabs = store.getState().getAllPaneTabs();
|
|
expect(allTabs).toHaveLength(2);
|
|
const sessionIds = allTabs.map((t) => t.sessionId);
|
|
expect(sessionIds).toContain('s1');
|
|
expect(sessionIds).toContain('s2');
|
|
});
|
|
});
|
|
|
|
describe('moveTabToNewPane', () => {
|
|
it('creates a new pane and moves the tab there', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.moveTabToNewPane(tab1Id, 'pane-default', 'pane-default', 'right');
|
|
|
|
const { paneLayout } = store.getState();
|
|
expect(paneLayout.panes).toHaveLength(2);
|
|
|
|
const sourcePane = paneLayout.panes.find((p) => p.id === 'pane-default')!;
|
|
const newPane = paneLayout.panes.find((p) => p.id !== 'pane-default')!;
|
|
|
|
expect(sourcePane.tabs).toHaveLength(1);
|
|
expect(sourcePane.tabs[0].sessionId).toBe('s2');
|
|
expect(newPane.tabs).toHaveLength(1);
|
|
expect(newPane.tabs[0].sessionId).toBe('s1');
|
|
});
|
|
|
|
it('respects MAX_PANES limit', () => {
|
|
const state = store.getState();
|
|
for (let i = 0; i < MAX_PANES + 1; i++) {
|
|
state.openTab({
|
|
type: 'session',
|
|
sessionId: `s${i}`,
|
|
projectId: 'p1',
|
|
label: `Session ${i}`,
|
|
});
|
|
}
|
|
|
|
// Split until MAX_PANES
|
|
for (let i = 0; i < MAX_PANES - 1; i++) {
|
|
const currentState = store.getState();
|
|
const focusedPane = currentState.paneLayout.panes.find(
|
|
(p) => p.id === currentState.paneLayout.focusedPaneId
|
|
);
|
|
if (focusedPane && focusedPane.tabs.length > 1) {
|
|
currentState.splitPane(focusedPane.id, focusedPane.tabs[0].id, 'right');
|
|
}
|
|
}
|
|
|
|
const paneCountBefore = store.getState().paneLayout.panes.length;
|
|
if (paneCountBefore >= MAX_PANES) {
|
|
// Attempt should be no-op
|
|
const focusedPane = store
|
|
.getState()
|
|
.paneLayout.panes.find((p) => p.id === store.getState().paneLayout.focusedPaneId);
|
|
if (focusedPane && focusedPane.tabs.length > 0) {
|
|
store
|
|
.getState()
|
|
.moveTabToNewPane(focusedPane.tabs[0].id, focusedPane.id, focusedPane.id, 'right');
|
|
expect(store.getState().paneLayout.panes.length).toBe(paneCountBefore);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('integration with tabSlice', () => {
|
|
it('openTab adds to focused pane', () => {
|
|
store.getState().openTab({
|
|
type: 'session',
|
|
sessionId: 's1',
|
|
projectId: 'p1',
|
|
label: 'Session 1',
|
|
});
|
|
|
|
const pane = store.getState().paneLayout.panes[0];
|
|
expect(pane.tabs).toHaveLength(1);
|
|
expect(pane.tabs[0].sessionId).toBe('s1');
|
|
|
|
// Root-level state should be synced
|
|
expect(store.getState().openTabs).toHaveLength(1);
|
|
expect(store.getState().activeTabId).toBe(pane.tabs[0].id);
|
|
});
|
|
|
|
it('closeTab removes from the containing pane', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
const tabToClose = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
store.getState().closeTab(tabToClose);
|
|
|
|
const pane = store.getState().paneLayout.panes[0];
|
|
expect(pane.tabs).toHaveLength(1);
|
|
expect(pane.tabs[0].sessionId).toBe('s2');
|
|
});
|
|
|
|
it('setActiveTab focuses the pane containing the tab', () => {
|
|
const state = store.getState();
|
|
state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' });
|
|
state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' });
|
|
|
|
// Split to create two panes
|
|
const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id;
|
|
state.splitPane('pane-default', tab1Id, 'right');
|
|
|
|
// Now s2 is in pane-default, s1 is in new pane
|
|
// Focus pane-default
|
|
store.getState().focusPane('pane-default');
|
|
expect(store.getState().paneLayout.focusedPaneId).toBe('pane-default');
|
|
|
|
// Set active tab to s1 (in other pane) - should focus that pane
|
|
store.getState().setActiveTab(tab1Id);
|
|
const newPaneId = store.getState().paneLayout.panes.find((p) => p.id !== 'pane-default')?.id;
|
|
expect(store.getState().paneLayout.focusedPaneId).toBe(newPaneId);
|
|
});
|
|
});
|
|
});
|