refactor(ui): panel api (WIP)

This commit is contained in:
psychedelicious
2025-07-04 07:42:04 +10:00
parent dde5bf61be
commit 4a18e9eaea
3 changed files with 555 additions and 1 deletions

View File

@@ -0,0 +1,305 @@
import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { GenerateTabLayout } from './navigation-api-2';
import { AppNavigationApi } from './navigation-api-2';
// Mock the logger
vi.mock('app/logging/logger', () => ({
logger: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}));
// Mock panel with setActive method
const createMockPanel = () =>
({
api: {
setActive: vi.fn(),
},
// Add other required properties as needed
}) as unknown as IGridviewPanel;
const createMockDockPanel = () =>
({
api: {
setActive: vi.fn(),
},
// Add other required properties as needed
}) as unknown as IDockviewPanel;
// Create a mock layout for testing
const createMockGenerateLayout = (): GenerateTabLayout => ({
gridviewApi: {} as Readonly<GridviewApi>,
panels: {
left: {
panelApi: {} as Readonly<IGridviewPanel>,
gridviewApi: {} as Readonly<GridviewApi>,
panels: {
settings: createMockPanel(),
},
},
main: {
panelApi: {} as Readonly<IGridviewPanel>,
dockviewApi: {} as Readonly<DockviewApi>,
panels: {
launchpad: createMockDockPanel(),
viewer: createMockDockPanel(),
},
},
right: {
panelApi: {} as Readonly<IGridviewPanel>,
gridviewApi: {} as Readonly<GridviewApi>,
panels: {
boards: createMockPanel(),
gallery: createMockPanel(),
},
},
},
});
describe('AppNavigationApi', () => {
let navigationApi: AppNavigationApi;
let mockSetAppTab: ReturnType<typeof vi.fn>;
let mockGetAppTab: ReturnType<typeof vi.fn>;
beforeEach(() => {
navigationApi = new AppNavigationApi();
mockSetAppTab = vi.fn();
mockGetAppTab = vi.fn();
});
describe('Basic Connection', () => {
it('should connect to app', () => {
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
expect(navigationApi.setAppTab).toBe(mockSetAppTab);
expect(navigationApi.getAppTab).toBe(mockGetAppTab);
});
it('should disconnect from app', () => {
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
navigationApi.disconnectFromApp();
expect(navigationApi.setAppTab).toBeNull();
expect(navigationApi.getAppTab).toBeNull();
});
});
describe('Tab Registration', () => {
it('should register and unregister tabs', () => {
const mockLayout = createMockGenerateLayout();
const unregister = navigationApi.registerAppTab('generate', mockLayout);
expect(typeof unregister).toBe('function');
// Check that tab is registered using type assertion to access private property
expect(navigationApi.appTabApi.generate).toBe(mockLayout);
// Unregister
unregister();
expect(navigationApi.appTabApi.generate).toBeNull();
});
it('should notify waiters when tab is registered', async () => {
const mockLayout = createMockGenerateLayout();
// Start waiting for registration
const waitPromise = navigationApi.waitForTabRegistration('generate');
// Register the tab
navigationApi.registerAppTab('generate', mockLayout);
// Wait should resolve
await expect(waitPromise).resolves.toBeUndefined();
});
});
describe('Panel Focus', () => {
beforeEach(() => {
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
});
it('should focus panel in already registered tab', async () => {
const mockLayout = createMockGenerateLayout();
navigationApi.registerAppTab('generate', mockLayout);
mockGetAppTab.mockReturnValue('generate');
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
expect(result).toBe(true);
expect(mockSetAppTab).not.toHaveBeenCalled();
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
});
it('should switch tab before focusing panel', async () => {
const mockLayout = createMockGenerateLayout();
navigationApi.registerAppTab('generate', mockLayout);
mockGetAppTab.mockReturnValue('canvas'); // Currently on different tab
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
expect(result).toBe(true);
expect(mockSetAppTab).toHaveBeenCalledWith('generate');
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
});
it('should wait for tab registration before focusing', async () => {
const mockLayout = createMockGenerateLayout();
mockGetAppTab.mockReturnValue('generate');
// Start focus operation before tab is registered
const focusPromise = navigationApi.focusPanelInTab('generate', 'left', 'settings');
// Register tab after a short delay
setTimeout(() => {
navigationApi.registerAppTab('generate', mockLayout);
}, 100);
const result = await focusPromise;
expect(result).toBe(true);
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
});
it('should focus different panel types', async () => {
const mockLayout = createMockGenerateLayout();
navigationApi.registerAppTab('generate', mockLayout);
mockGetAppTab.mockReturnValue('generate');
// Test gridview panel
const result1 = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
expect(result1).toBe(true);
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
// Test dockview panel
const result2 = await navigationApi.focusPanelInTab('generate', 'main', 'launchpad');
expect(result2).toBe(true);
expect(mockLayout.panels.main.panels.launchpad.api.setActive).toHaveBeenCalledOnce();
// Test right panel
const result3 = await navigationApi.focusPanelInTab('generate', 'right', 'boards');
expect(result3).toBe(true);
expect(mockLayout.panels.right.panels.boards.api.setActive).toHaveBeenCalledOnce();
});
it('should return false on registration timeout', async () => {
mockGetAppTab.mockReturnValue('generate');
// Set a short timeout for testing
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
expect(result).toBe(false);
});
it('should handle errors gracefully', async () => {
const mockLayout = createMockGenerateLayout();
// Make setActive throw an error
vi.mocked(mockLayout.panels.left.panels.settings.api.setActive).mockImplementation(() => {
throw new Error('Mock error');
});
navigationApi.registerAppTab('generate', mockLayout);
mockGetAppTab.mockReturnValue('generate');
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
expect(result).toBe(false);
});
it('should work without app connection', async () => {
const mockLayout = createMockGenerateLayout();
navigationApi.registerAppTab('generate', mockLayout);
// Don't connect to app
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
expect(result).toBe(true);
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
});
});
describe('Registration Waiting', () => {
it('should resolve immediately for already registered tabs', async () => {
const mockLayout = createMockGenerateLayout();
navigationApi.registerAppTab('generate', mockLayout);
const waitPromise = navigationApi.waitForTabRegistration('generate');
await expect(waitPromise).resolves.toBeUndefined();
});
it('should handle multiple waiters', async () => {
const mockLayout = createMockGenerateLayout();
const waitPromise1 = navigationApi.waitForTabRegistration('generate');
const waitPromise2 = navigationApi.waitForTabRegistration('generate');
setTimeout(() => {
navigationApi.registerAppTab('generate', mockLayout);
}, 50);
await expect(Promise.all([waitPromise1, waitPromise2])).resolves.toEqual([undefined, undefined]);
});
it('should timeout if tab is not registered', async () => {
const waitPromise = navigationApi.waitForTabRegistration('generate', 100);
await expect(waitPromise).rejects.toThrow('Tab generate registration timed out');
});
});
describe('Integration Tests', () => {
it('should handle complete workflow', async () => {
const mockLayout = createMockGenerateLayout();
// Connect to app
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
mockGetAppTab.mockReturnValue('canvas');
// Register tab
const unregister = navigationApi.registerAppTab('generate', mockLayout);
// Focus panel (should switch tab and focus)
const result = await navigationApi.focusPanelInTab('generate', 'right', 'gallery');
expect(result).toBe(true);
expect(mockSetAppTab).toHaveBeenCalledWith('generate');
expect(mockLayout.panels.right.panels.gallery.api.setActive).toHaveBeenCalledOnce();
// Cleanup
unregister();
navigationApi.disconnectFromApp();
expect(navigationApi.setAppTab).toBeNull();
expect(navigationApi.getAppTab).toBeNull();
});
it('should handle multiple tabs sequentially', async () => {
const mockLayout1 = createMockGenerateLayout();
const mockLayout2 = createMockGenerateLayout();
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
mockGetAppTab.mockReturnValue('generate');
// Register first tab
const unregister1 = navigationApi.registerAppTab('generate', mockLayout1);
// Focus panel in first tab
await navigationApi.focusPanelInTab('generate', 'left', 'settings');
expect(mockLayout1.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
// Replace with second tab
unregister1();
navigationApi.registerAppTab('generate', mockLayout2);
// Focus panel in second tab
await navigationApi.focusPanelInTab('generate', 'right', 'boards');
expect(mockLayout2.panels.right.panels.boards.api.setActive).toHaveBeenCalledOnce();
});
});
});

View File

@@ -0,0 +1,235 @@
import { logger } from 'app/logging/logger';
import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
import type { TabName } from 'features/ui/store/uiTypes';
const log = logger('system');
export type GenerateTabLayout = {
gridviewApi: Readonly<GridviewApi>;
panels: {
left: {
panelApi: Readonly<IGridviewPanel>;
gridviewApi: Readonly<GridviewApi>;
panels: {
settings: Readonly<IGridviewPanel>;
};
};
main: {
panelApi: Readonly<IGridviewPanel>;
dockviewApi: Readonly<DockviewApi>;
panels: {
launchpad: Readonly<IDockviewPanel>;
viewer: Readonly<IDockviewPanel>;
};
};
right: {
panelApi: Readonly<IGridviewPanel>;
gridviewApi: Readonly<GridviewApi>;
panels: {
boards: Readonly<IGridviewPanel>;
gallery: Readonly<IGridviewPanel>;
};
};
};
};
export type CanvasTabLayout = {
gridviewApi: Readonly<GridviewApi>;
panels: {
left: {
panelApi: Readonly<IGridviewPanel>;
gridviewApi: Readonly<GridviewApi>;
panels: {
settings: Readonly<IGridviewPanel>;
};
};
main: {
panelApi: Readonly<IGridviewPanel>;
dockviewApi: Readonly<DockviewApi>;
panels: {
launchpad: Readonly<IDockviewPanel>;
workspace: Readonly<IDockviewPanel>;
viewer: Readonly<IDockviewPanel>;
};
};
right: {
panelApi: Readonly<IGridviewPanel>;
gridviewApi: Readonly<GridviewApi>;
panels: {
boards: Readonly<IGridviewPanel>;
gallery: Readonly<IGridviewPanel>;
layers: Readonly<IGridviewPanel>;
};
};
};
};
export type UpscalingTabLayout = {
gridviewApi: Readonly<GridviewApi>;
panels: {
left: {
panelApi: Readonly<IGridviewPanel>;
gridviewApi: Readonly<GridviewApi>;
panels: {
settings: Readonly<IGridviewPanel>;
};
};
main: {
panelApi: Readonly<IGridviewPanel>;
dockviewApi: Readonly<DockviewApi>;
panels: {
launchpad: Readonly<IDockviewPanel>;
viewer: Readonly<IDockviewPanel>;
};
};
right: {
panelApi: Readonly<IGridviewPanel>;
gridviewApi: Readonly<GridviewApi>;
panels: {
boards: Readonly<IGridviewPanel>;
gallery: Readonly<IGridviewPanel>;
};
};
};
};
export type WorkflowsTabLayout = {
gridviewApi: Readonly<GridviewApi>;
panels: {
left: {
panelApi: Readonly<IGridviewPanel>;
gridviewApi: Readonly<GridviewApi>;
panels: {
settings: Readonly<IGridviewPanel>;
};
};
main: {
panelApi: Readonly<IGridviewPanel>;
dockviewApi: Readonly<DockviewApi>;
panels: {
launchpad: Readonly<IDockviewPanel>;
workspace: Readonly<IDockviewPanel>;
viewer: Readonly<IDockviewPanel>;
};
};
right: {
panelApi: Readonly<IGridviewPanel>;
gridviewApi: Readonly<GridviewApi>;
panels: {
boards: Readonly<IGridviewPanel>;
gallery: Readonly<IGridviewPanel>;
};
};
};
};
type AppTabApi = {
generate: GenerateTabLayout | null;
canvas: CanvasTabLayout | null;
upscaling: UpscalingTabLayout | null;
workflows: WorkflowsTabLayout | null;
};
export class AppNavigationApi {
appTabApi: AppTabApi = {
generate: null,
canvas: null,
upscaling: null,
workflows: null,
};
setAppTab: ((tab: TabName) => void) | null = null;
getAppTab: (() => TabName) | null = null;
private registrationWaiters: Map<keyof AppTabApi, Array<() => void>> = new Map();
connectToApp(arg: { setAppTab: (tab: TabName) => void; getAppTab: () => TabName }): void {
const { setAppTab, getAppTab } = arg;
this.setAppTab = setAppTab;
this.getAppTab = getAppTab;
}
disconnectFromApp(): void {
this.setAppTab = null;
this.getAppTab = null;
}
registerAppTab<T extends keyof AppTabApi>(tab: T, layout: AppTabApi[T]): () => void {
this.appTabApi[tab] = layout;
// Notify any waiting consumers
const waiters = this.registrationWaiters.get(tab);
if (waiters) {
waiters.forEach((resolve) => resolve());
this.registrationWaiters.delete(tab);
}
return () => {
this.appTabApi[tab] = null;
};
}
/**
* Focus a specific panel in a specific tab
* Automatically switches to the target tab if specified
*/
async focusPanelInTab<
T extends keyof AppTabApi,
R extends keyof NonNullable<AppTabApi[T]>['panels'],
P extends keyof (NonNullable<AppTabApi[T]>['panels'][R] extends { panels: infer Panels } ? Panels : never),
>(tabName: T, rootPanelId: R, panelId: P): Promise<boolean> {
try {
if (this.setAppTab && this.getAppTab && this.getAppTab() !== tabName) {
this.setAppTab(tabName);
}
await this.waitForTabRegistration(tabName);
const tabLayout = this.appTabApi[tabName];
if (!tabLayout) {
log.error(`Tab ${tabName} failed to register`);
return false;
}
const panel = tabLayout.panels[rootPanelId].panels[panelId] as IGridviewPanel | IDockviewPanel;
panel.api.setActive();
return true;
} catch (error) {
log.error(`Failed to focus panel ${String(panelId)} in tab ${tabName}`);
return false;
}
}
waitForTabRegistration<T extends keyof AppTabApi>(tabName: T, timeout = 1000): Promise<void> {
return new Promise((resolve, reject) => {
if (this.appTabApi[tabName]) {
resolve();
return;
}
let waiters = this.registrationWaiters.get(tabName);
if (!waiters) {
waiters = [];
this.registrationWaiters.set(tabName, waiters);
}
waiters.push(resolve);
const intervalId = setInterval(() => {
if (this.appTabApi[tabName]) {
resolve();
}
}, 100);
setTimeout(() => {
clearInterval(intervalId);
if (this.appTabApi[tabName]) {
resolve();
} else {
reject(new Error(`Tab ${tabName} registration timed out`));
}
}, timeout);
});
}
}
export const navigationApi = new AppNavigationApi();

View File

@@ -3,9 +3,10 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import type { TabName } from 'features/ui/store/uiTypes';
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { navigationApi } from './navigation-api';
import { navigationApi as navigationApi2 } from './navigation-api-2';
/**
* Hook that initializes the global navigation API with callbacks to access and modify the active tab.
@@ -28,4 +29,17 @@ export const useNavigationApi = () => {
useEffect(() => {
navigationApi.setTabApi(tabApi);
}, [store, tabApi]);
const getAppTab = useCallback(() => {
return selectActiveTab(store.getState());
}, [store]);
const setAppTab = useCallback(
(tab: TabName) => {
store.dispatch(setActiveTab(tab));
},
[store]
);
useEffect(() => {
navigationApi2.connectToApp({ getAppTab, setAppTab });
}, [getAppTab, setAppTab, store, tabApi]);
};