refactor(ui): navigation api

This commit is contained in:
psychedelicious
2025-07-04 11:40:05 +10:00
parent 4a18e9eaea
commit bcced8a5e8
23 changed files with 749 additions and 946 deletions

View File

@@ -0,0 +1,20 @@
export type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
};
/**
* Create a promise and expose its resolve and reject callbacks.
*/
export const createDeferredPromise = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};

View File

@@ -15,7 +15,7 @@ export const CanvasLaunchpadPanel = memo(() => {
const { t } = useTranslation();
const { tab } = useAutoLayoutContext();
const focusCanvas = useCallback(() => {
navigationApi.focusPanelInTab(tab, WORKSPACE_PANEL_ID);
navigationApi.focusPanel(tab, WORKSPACE_PANEL_ID);
}, [tab]);
return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>

View File

@@ -24,7 +24,6 @@ export const BoardsPanel = memo(() => {
const { tab } = useAutoLayoutContext();
const collapsibleApi = useCollapsibleGridviewPanel(
tab,
'right',
BOARDS_PANEL_ID,
'vertical',
BOARD_PANEL_DEFAULT_HEIGHT_PX,

View File

@@ -35,7 +35,6 @@ export const GalleryPanel = memo(() => {
const { tab } = useAutoLayoutContext();
const collapsibleApi = useCollapsibleGridviewPanel(
tab,
'right',
GALLERY_PANEL_ID,
'vertical',
GALLERY_PANEL_DEFAULT_HEIGHT_PX,

View File

@@ -21,7 +21,7 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState });
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -32,7 +32,7 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState });
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -43,7 +43,7 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState });
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -54,7 +54,7 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState });
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),

View File

@@ -24,7 +24,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -36,7 +36,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -48,7 +48,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -60,7 +60,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -72,7 +72,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),

View File

@@ -59,7 +59,7 @@ export const CurrentImageButtons = memo(() => {
getState,
dispatch,
});
navigationApi.focusPanelInTab('canvas', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
// Automatically select the brush tool when editing an image
if (canvasManager) {

View File

@@ -21,7 +21,7 @@ export const WorkflowViewEditToggleButton = memo(() => {
dispatch(setActiveTab('workflows'));
dispatch(workflowModeChanged('edit'));
// Focus the Workflow Editor panel
navigationApi.focusPanelInTab('workflows', WORKSPACE_PANEL_ID);
navigationApi.focusPanel('workflows', WORKSPACE_PANEL_ID);
},
[dispatch]
);
@@ -33,7 +33,7 @@ export const WorkflowViewEditToggleButton = memo(() => {
dispatch(setActiveTab('workflows'));
dispatch(workflowModeChanged('view'));
// Focus the Image Viewer panel
navigationApi.focusPanelInTab('workflows', VIEWER_PANEL_ID);
navigationApi.focusPanel('workflows', VIEWER_PANEL_ID);
},
[dispatch]
);

View File

@@ -63,18 +63,18 @@ export const useInvoke = () => {
const enqueueBack = useCallback(() => {
enqueue(false, false);
if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') {
navigationApi.focusPanelInTab(tabName, VIEWER_PANEL_ID);
navigationApi.focusPanel(tabName, VIEWER_PANEL_ID);
} else if (tabName === 'canvas') {
navigationApi.focusPanelInTab(tabName, WORKSPACE_PANEL_ID);
navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID);
}
}, [enqueue, tabName]);
const enqueueFront = useCallback(() => {
enqueue(true, false);
if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') {
navigationApi.focusPanelInTab(tabName, VIEWER_PANEL_ID);
navigationApi.focusPanel(tabName, VIEWER_PANEL_ID);
} else if (tabName === 'canvas') {
navigationApi.focusPanelInTab(tabName, WORKSPACE_PANEL_ID);
navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID);
}
}, [enqueue, tabName]);

View File

@@ -5,9 +5,8 @@ import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/compone
import { InvokeButtonTooltip } from 'features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip';
import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem';
import { useInvoke } from 'features/queue/hooks/useInvoke';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
PiCircleNotchBold,
@@ -54,20 +53,12 @@ FloatingCanvasLeftPanelButtons.displayName = 'FloatingCanvasLeftPanelButtons';
const ToggleLeftPanelButton = memo(() => {
const { t } = useTranslation();
const { tab } = useAutoLayoutContext();
const onClick = useCallback(() => {
if (navigationApi.tabApi?.getTab() !== tab) {
return;
}
navigationApi.toggleLeftPanelInTab(tab);
}, [tab]);
return (
<Tooltip label={t('accessibility.toggleLeftPanel')} placement="end">
<IconButton
aria-label={t('accessibility.toggleLeftPanel')}
onClick={onClick}
onClick={navigationApi.toggleLeftPanel}
icon={<PiSlidersHorizontalBold />}
flexGrow={1}
/>

View File

@@ -1,7 +1,6 @@
import { Flex, IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImagesSquareBold } from 'react-icons/pi';
@@ -16,20 +15,12 @@ FloatingRightPanelButtons.displayName = 'FloatingRightPanelButtons';
const ToggleRightPanelButton = memo(() => {
const { t } = useTranslation();
const { tab } = useAutoLayoutContext();
const onClick = useCallback(() => {
if (navigationApi.tabApi?.getTab() !== tab) {
return;
}
navigationApi.toggleRightPanelInTab(tab);
}, [tab]);
return (
<Tooltip label={t('accessibility.toggleRightPanel')} placement="start">
<IconButton
aria-label={t('accessibility.toggleRightPanel')}
onClick={onClick}
onClick={navigationApi.toggleRightPanel}
icon={<PiImagesSquareBold />}
h={48}
/>

View File

@@ -2,54 +2,26 @@ import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/us
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo } from 'react';
import { useAutoLayoutContext } from './auto-layout-context';
export const PanelHotkeysLogical = memo(() => {
const { tab } = useAutoLayoutContext();
useRegisteredHotkeys({
category: 'app',
id: 'toggleLeftPanel',
callback: () => {
if (navigationApi.tabApi?.getTab() !== tab) {
return;
}
navigationApi.toggleLeftPanelInTab(tab);
},
dependencies: [tab],
callback: navigationApi.toggleLeftPanel,
});
useRegisteredHotkeys({
category: 'app',
id: 'toggleRightPanel',
callback: () => {
if (navigationApi.tabApi?.getTab() !== tab) {
return;
}
navigationApi.toggleRightPanelInTab(tab);
},
dependencies: [tab],
callback: navigationApi.toggleRightPanel,
});
useRegisteredHotkeys({
category: 'app',
id: 'resetPanelLayout',
callback: () => {
if (navigationApi.tabApi?.getTab() !== tab) {
return;
}
navigationApi.resetPanelsInTab(tab);
},
dependencies: [tab],
callback: navigationApi.resetLeftAndRightPanels,
});
useRegisteredHotkeys({
category: 'app',
id: 'togglePanels',
callback: () => {
if (navigationApi.tabApi?.getTab() !== tab) {
return;
}
navigationApi.toggleBothPanelsInTab(tab);
},
dependencies: [tab],
callback: navigationApi.toggleLeftAndRightPanels,
});
return null;

View File

@@ -113,6 +113,11 @@ const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
},
});
// Register panels with navigation API
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
return { launchpad, workspace, viewer } satisfies Record<string, IDockviewPanel>;
};
@@ -122,7 +127,6 @@ const MainPanel = memo(() => {
const onReady = useCallback<IDockviewReactProps['onReady']>(
({ api }) => {
const panels = initializeCenterPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'main', api);
panels.launchpad.api.setActive();
const disposables = [
@@ -211,6 +215,11 @@ export const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
boards.api.setSize({ height: CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
// Register panels with navigation API
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
navigationApi.registerPanel(tab, LAYERS_PANEL_ID, layers);
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
return { gallery, layers, boards } satisfies Record<string, IGridviewPanel>;
};
@@ -220,7 +229,6 @@ const RightPanel = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(
({ api }) => {
initializeRightPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'right', api);
},
[tab]
);
@@ -249,6 +257,9 @@ export const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
},
});
// Register panel with navigation API
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
return { settings } satisfies Record<string, IGridviewPanel>;
};
@@ -258,7 +269,6 @@ const LeftPanel = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(
({ api }) => {
initializeLeftPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'left', api);
},
[tab]
);
@@ -309,6 +319,10 @@ export const initializeRootPanelLayout = (api: GridviewApi) => {
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
navigationApi.registerPanel('canvas', LEFT_PANEL_ID, left);
navigationApi.registerPanel('canvas', MAIN_PANEL_ID, main);
navigationApi.registerPanel('canvas', RIGHT_PANEL_ID, right);
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
@@ -326,8 +340,9 @@ export const CanvasTabAutoLayout = memo(() => {
return;
}
initializeRootPanelLayout(rootApi);
navigationApi.registerPanel('canvas', 'root', rootApi);
navigationApi.focusPanelInTab('canvas', LAUNCHPAD_PANEL_ID, false);
// Focus the launchpad panel once it's ready
navigationApi.focusPanel('canvas', LAUNCHPAD_PANEL_ID);
return () => {
navigationApi.unregisterTab('canvas');

View File

@@ -92,6 +92,10 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
},
});
// Register panels with navigation API
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
return { launchpad, viewer } satisfies Record<string, IDockviewPanel>;
};
@@ -101,7 +105,6 @@ const MainPanel = memo(() => {
const onReady = useCallback<IDockviewReactProps['onReady']>(
({ api }) => {
const { launchpad } = initializeMainPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'main', api);
launchpad.api.setActive();
const disposables = [
@@ -175,6 +178,10 @@ export const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
// Register panels with navigation API
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
};
@@ -184,7 +191,6 @@ const RightPanel = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(
({ api }) => {
initializeRightPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'right', api);
},
[tab]
);
@@ -213,6 +219,9 @@ export const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
},
});
// Register panel with navigation API
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
return { settings } satisfies Record<string, IGridviewPanel>;
};
@@ -222,7 +231,6 @@ const LeftPanel = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(
({ api }) => {
initializeLeftPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'left', api);
},
[tab]
);
@@ -273,6 +281,10 @@ export const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
navigationApi.registerPanel('generate', LEFT_PANEL_ID, left);
navigationApi.registerPanel('generate', MAIN_PANEL_ID, main);
navigationApi.registerPanel('generate', RIGHT_PANEL_ID, right);
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
@@ -290,8 +302,9 @@ export const GenerateTabAutoLayout = memo(() => {
return;
}
initializeRootPanelLayout(rootApi);
navigationApi.registerPanel('generate', 'root', rootApi);
navigationApi.focusPanelInTab('generate', LAUNCHPAD_PANEL_ID, false);
// Focus the launchpad panel once it's ready
navigationApi.focusPanel('generate', LAUNCHPAD_PANEL_ID);
return () => {
navigationApi.unregisterTab('generate');

View File

@@ -1,305 +0,0 @@
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

@@ -1,235 +0,0 @@
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

@@ -0,0 +1,368 @@
import type { IDockviewPanel, IGridviewPanel } from 'dockview';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NavigationApi } from './navigation-api';
// 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(),
},
}) as unknown as IGridviewPanel;
const createMockDockPanel = () =>
({
api: {
setActive: vi.fn(),
},
}) as unknown as IDockviewPanel;
describe('AppNavigationApi', () => {
let navigationApi: NavigationApi;
let mockSetAppTab: ReturnType<typeof vi.fn>;
let mockGetAppTab: ReturnType<typeof vi.fn>;
beforeEach(() => {
navigationApi = new NavigationApi();
mockSetAppTab = vi.fn();
mockGetAppTab = vi.fn();
});
afterEach(() => {
// Clean up all panels and pending promises to prevent unhandled rejections
navigationApi.unregisterTab('generate');
navigationApi.unregisterTab('canvas');
navigationApi.unregisterTab('upscaling');
navigationApi.unregisterTab('workflows');
});
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('Panel Registration', () => {
it('should register and unregister panels', () => {
const mockPanel = createMockPanel();
const unregister = navigationApi.registerPanel('generate', 'settings', mockPanel);
expect(typeof unregister).toBe('function');
expect(navigationApi.isPanelRegistered('generate', 'settings')).toBe(true);
// Unregister
unregister();
expect(navigationApi.isPanelRegistered('generate', 'settings')).toBe(false);
});
it('should resolve waiting promises when panel is registered', async () => {
const mockPanel = createMockPanel();
// Start waiting for registration
const waitPromise = navigationApi.waitForPanel('generate', 'settings');
// Register the panel
navigationApi.registerPanel('generate', 'settings', mockPanel);
// Wait should resolve
await expect(waitPromise).resolves.toBeUndefined();
});
it('should handle multiple panels per tab', () => {
const mockPanel1 = createMockPanel();
const mockPanel2 = createMockDockPanel();
navigationApi.registerPanel('generate', 'settings', mockPanel1);
navigationApi.registerPanel('generate', 'launchpad', mockPanel2);
expect(navigationApi.isPanelRegistered('generate', 'settings')).toBe(true);
expect(navigationApi.isPanelRegistered('generate', 'launchpad')).toBe(true);
const registeredPanels = navigationApi.getRegisteredPanels('generate');
expect(registeredPanels).toContain('settings');
expect(registeredPanels).toContain('launchpad');
});
it('should handle panels across different tabs', () => {
const mockPanel1 = createMockPanel();
const mockPanel2 = createMockPanel();
navigationApi.registerPanel('generate', 'settings', mockPanel1);
navigationApi.registerPanel('canvas', 'settings', mockPanel2);
expect(navigationApi.isPanelRegistered('generate', 'settings')).toBe(true);
expect(navigationApi.isPanelRegistered('canvas', 'settings')).toBe(true);
// Same panel ID in different tabs should be separate
expect(navigationApi.getRegisteredPanels('generate')).toEqual(['settings']);
expect(navigationApi.getRegisteredPanels('canvas')).toEqual(['settings']);
});
});
describe('Panel Focus', () => {
beforeEach(() => {
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
});
it('should focus panel in already registered tab', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', 'settings', mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = await navigationApi.focusPanel('generate', 'settings');
expect(result).toBe(true);
expect(mockSetAppTab).not.toHaveBeenCalled();
expect(mockPanel.api.setActive).toHaveBeenCalledOnce();
});
it('should switch tab before focusing panel', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', 'settings', mockPanel);
mockGetAppTab.mockReturnValue('canvas'); // Currently on different tab
const result = await navigationApi.focusPanel('generate', 'settings');
expect(result).toBe(true);
expect(mockSetAppTab).toHaveBeenCalledWith('generate');
expect(mockPanel.api.setActive).toHaveBeenCalledOnce();
});
it('should wait for panel registration before focusing', async () => {
const mockPanel = createMockPanel();
mockGetAppTab.mockReturnValue('generate');
// Start focus operation before panel is registered
const focusPromise = navigationApi.focusPanel('generate', 'settings');
// Register panel after a short delay
setTimeout(() => {
navigationApi.registerPanel('generate', 'settings', mockPanel);
}, 100);
const result = await focusPromise;
expect(result).toBe(true);
expect(mockPanel.api.setActive).toHaveBeenCalledOnce();
});
it('should focus different panel types', async () => {
const mockGridPanel = createMockPanel();
const mockDockPanel = createMockDockPanel();
navigationApi.registerPanel('generate', 'settings', mockGridPanel);
navigationApi.registerPanel('generate', 'launchpad', mockDockPanel);
mockGetAppTab.mockReturnValue('generate');
// Test gridview panel
const result1 = await navigationApi.focusPanel('generate', 'settings');
expect(result1).toBe(true);
expect(mockGridPanel.api.setActive).toHaveBeenCalledOnce();
// Test dockview panel
const result2 = await navigationApi.focusPanel('generate', 'launchpad');
expect(result2).toBe(true);
expect(mockDockPanel.api.setActive).toHaveBeenCalledOnce();
});
it('should return false on registration timeout', async () => {
mockGetAppTab.mockReturnValue('generate');
// Set a short timeout for testing
const result = await navigationApi.focusPanel('generate', 'settings');
expect(result).toBe(false);
});
it('should handle errors gracefully', async () => {
const mockPanel = createMockPanel();
// Make setActive throw an error
vi.mocked(mockPanel.api.setActive).mockImplementation(() => {
throw new Error('Mock error');
});
navigationApi.registerPanel('generate', 'settings', mockPanel);
mockGetAppTab.mockReturnValue('generate');
const result = await navigationApi.focusPanel('generate', 'settings');
expect(result).toBe(false);
});
it('should work without app connection', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', 'settings', mockPanel);
// Don't connect to app
const result = await navigationApi.focusPanel('generate', 'settings');
expect(result).toBe(true);
expect(mockPanel.api.setActive).toHaveBeenCalledOnce();
});
});
describe('Panel Waiting', () => {
it('should resolve immediately for already registered panels', async () => {
const mockPanel = createMockPanel();
navigationApi.registerPanel('generate', 'settings', mockPanel);
const waitPromise = navigationApi.waitForPanel('generate', 'settings');
await expect(waitPromise).resolves.toBeUndefined();
});
it('should handle multiple waiters for same panel', async () => {
const mockPanel = createMockPanel();
const waitPromise1 = navigationApi.waitForPanel('generate', 'settings');
const waitPromise2 = navigationApi.waitForPanel('generate', 'settings');
setTimeout(() => {
navigationApi.registerPanel('generate', 'settings', mockPanel);
}, 50);
await expect(Promise.all([waitPromise1, waitPromise2])).resolves.toEqual([undefined, undefined]);
});
it('should timeout if panel is not registered', async () => {
const waitPromise = navigationApi.waitForPanel('generate', 'settings', 100);
await expect(waitPromise).rejects.toThrow('Panel generate:settings registration timed out after 100ms');
});
it('should handle custom timeout', async () => {
const start = Date.now();
const waitPromise = navigationApi.waitForPanel('generate', 'settings', 200);
await expect(waitPromise).rejects.toThrow('Panel generate:settings registration timed out after 200ms');
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(200);
expect(elapsed).toBeLessThan(300); // Allow some margin
});
});
describe('Tab Management', () => {
it('should unregister all panels for a tab', () => {
const mockPanel1 = createMockPanel();
const mockPanel2 = createMockPanel();
const mockPanel3 = createMockPanel();
navigationApi.registerPanel('generate', 'settings', mockPanel1);
navigationApi.registerPanel('generate', 'launchpad', mockPanel2);
navigationApi.registerPanel('canvas', 'settings', mockPanel3);
expect(navigationApi.getRegisteredPanels('generate')).toHaveLength(2);
expect(navigationApi.getRegisteredPanels('canvas')).toHaveLength(1);
navigationApi.unregisterTab('generate');
expect(navigationApi.getRegisteredPanels('generate')).toHaveLength(0);
expect(navigationApi.getRegisteredPanels('canvas')).toHaveLength(1);
});
it('should clean up pending promises when unregistering tab', async () => {
const waitPromise = navigationApi.waitForPanel('generate', 'settings', 5000);
navigationApi.unregisterTab('generate');
// The promise should reject with cancellation message since we cleaned up
await expect(waitPromise).rejects.toThrow('Panel registration cancelled - tab generate was unregistered');
});
});
describe('Integration Tests', () => {
it('should handle complete workflow', async () => {
const mockPanel = createMockPanel();
// Connect to app
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
mockGetAppTab.mockReturnValue('canvas');
// Register panel
const unregister = navigationApi.registerPanel('generate', 'settings', mockPanel);
// Focus panel (should switch tab and focus)
const result = await navigationApi.focusPanel('generate', 'settings');
expect(result).toBe(true);
expect(mockSetAppTab).toHaveBeenCalledWith('generate');
expect(mockPanel.api.setActive).toHaveBeenCalledOnce();
// Cleanup
unregister();
navigationApi.disconnectFromApp();
expect(navigationApi.setAppTab).toBeNull();
expect(navigationApi.getAppTab).toBeNull();
expect(navigationApi.isPanelRegistered('generate', 'settings')).toBe(false);
});
it('should handle multiple panels and tabs', async () => {
const mockPanel1 = createMockPanel();
const mockPanel2 = createMockDockPanel();
const mockPanel3 = createMockPanel();
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
mockGetAppTab.mockReturnValue('generate');
// Register panels
navigationApi.registerPanel('generate', 'settings', mockPanel1);
navigationApi.registerPanel('generate', 'launchpad', mockPanel2);
navigationApi.registerPanel('canvas', 'workspace', mockPanel3);
// Focus panels
await navigationApi.focusPanel('generate', 'settings');
expect(mockPanel1.api.setActive).toHaveBeenCalledOnce();
await navigationApi.focusPanel('generate', 'launchpad');
expect(mockPanel2.api.setActive).toHaveBeenCalledOnce();
mockGetAppTab.mockReturnValue('generate');
await navigationApi.focusPanel('canvas', 'workspace');
expect(mockSetAppTab).toHaveBeenCalledWith('canvas');
expect(mockPanel3.api.setActive).toHaveBeenCalledOnce();
});
it('should handle async registration and focus', async () => {
const mockPanel = createMockPanel();
mockGetAppTab.mockReturnValue('generate');
// Start focusing before registration
const focusPromise = navigationApi.focusPanel('generate', 'settings');
// Register after delay
setTimeout(() => {
navigationApi.registerPanel('generate', 'settings', mockPanel);
}, 50);
const result = await focusPromise;
expect(result).toBe(true);
expect(mockPanel.api.setActive).toHaveBeenCalledOnce();
});
});
});

View File

@@ -1,212 +1,197 @@
import { logger } from 'app/logging/logger';
import type { DockviewApi, GridviewApi, IGridviewPanel } from 'dockview';
import { LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_MIN_SIZE_PX } from 'features/ui/layouts/shared';
import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise';
import { GridviewPanel, type IDockviewPanel, type IGridviewPanel } from 'dockview';
import type { TabName } from 'features/ui/store/uiTypes';
import { LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_MIN_SIZE_PX } from './shared';
const log = logger('system');
export type TabPanelApis = {
root: Readonly<GridviewApi> | null;
left: Readonly<GridviewApi> | null;
main: Readonly<DockviewApi> | null;
right: Readonly<GridviewApi> | null;
type PanelType = IGridviewPanel | IDockviewPanel;
type Waiter = {
deferred: Deferred<void>;
timeoutId: ReturnType<typeof setTimeout> | null;
};
const getInitialTabPanelApis = (): TabPanelApis => ({
root: null,
left: null,
main: null,
right: null,
});
const PANEL_ENABLED_TABS: TabName[] = ['canvas', 'generate', 'workflows', 'queue'];
type AppTabApi = {
setTab: (tabName: TabName) => void;
getTab: () => TabName;
};
export class NavigationApi {
private panels: Map<string, PanelType> = new Map();
private waiters: Map<string, Waiter> = new Map();
/**
* Global API for managing application navigation and tab panels.
*/
export class AppNavigationApi {
private tabPanelApis = new Map<TabName, TabPanelApis>();
setAppTab: ((tab: TabName) => void) | null = null;
getAppTab: (() => TabName) | null = null;
tabApi: AppTabApi | null = null;
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;
};
/**
* Set the Redux store reference for tab switching
* Register a panel with a unique ID
* @param tab - The tab this panel belongs to
* @param panelId - Unique identifier for the panel
* @param panel - The panel instance
* @returns Cleanup function to unregister the panel
*/
setTabApi(tabApi: AppTabApi): void {
this.tabApi = tabApi;
}
registerPanel = (tab: TabName, panelId: string, panel: PanelType): (() => void) => {
const key = `${tab}:${panelId}`;
/**
* Register panel APIs for a specific tab
*/
registerPanel<T extends keyof TabPanelApis>(tab: TabName, panel: T, api: NonNullable<TabPanelApis[T]>): void {
const current = this.tabPanelApis.get(tab) ?? getInitialTabPanelApis();
const apis: TabPanelApis = {
...current,
[panel]: api,
this.panels.set(key, panel);
// Resolve any waiting promises
const waiter = this.waiters.get(key);
if (waiter) {
if (waiter.timeoutId) {
// Clear the timeout if it exists
clearTimeout(waiter.timeoutId);
}
waiter.deferred.resolve();
this.waiters.delete(key);
}
log.debug(`Registered panel ${key}`);
return () => {
this.panels.delete(key);
log.debug(`Unregistered panel ${key}`);
};
this.tabPanelApis.set(tab, apis);
}
};
/**
* Unregister panel APIs for a specific tab
* Wait for a panel to be ready
* @param tab - The tab the panel belongs to
* @param panelId - The panel ID to wait for
* @param timeout - Timeout in milliseconds (default: 2000)
* @returns Promise that resolves when the panel is ready
*/
unregisterPanel<T extends keyof TabPanelApis>(tab: TabName, panel: T): void {
const current = this.tabPanelApis.get(tab);
if (!current) {
return;
}
const apis: TabPanelApis = {
...current,
[panel]: null,
};
this.tabPanelApis.set(tab, apis);
}
/**
* Unregister panel APIs for a specific tab
*/
unregisterTab(tabName: TabName): void {
this.tabPanelApis.delete(tabName);
}
/**
* Get panel APIs for a specific tab
*/
getTabPanelApis(tabName: TabName): TabPanelApis | null {
return this.tabPanelApis.get(tabName) || null;
}
/**
* Get panel APIs for the currently active tab
*/
getActiveTabPanelApis(): TabPanelApis | null {
if (!this.tabApi) {
return null;
waitForPanel = (tab: TabName, panelId: string, timeout = 2000): Promise<void> => {
if (!PANEL_ENABLED_TABS.includes(tab)) {
log.error(`Tab ${tab} is not enabled for panel registration`);
return Promise.reject(new Error(`Tab ${tab} is not enabled for panel registration`));
}
const activeTab = this.tabApi.getTab();
return this.getTabPanelApis(activeTab);
}
const key = `${tab}:${panelId}`;
/**
* Get all registered tab names
*/
getRegisteredTabs(): TabName[] {
return Array.from(this.tabPanelApis.keys());
}
/**
* Check if a tab is registered
*/
isTabRegistered(tabName: TabName): boolean {
return this.tabPanelApis.has(tabName);
}
/**
* Switch to a specific tab
*/
private switchToTab(tabName: TabName): boolean {
if (!this.tabApi) {
log.warn(`Cannot switch to tab "${tabName}": no store reference`);
return false;
if (this.panels.has(key)) {
return Promise.resolve();
}
this.tabApi.setTab(tabName);
return true;
}
// Check if we already have a promise for this panel
const existing = this.waiters.get(key);
if (existing) {
return existing.deferred.promise;
}
const deferred = createDeferredPromise<void>();
const timeoutId = setTimeout(() => {
// Only reject if this deferred is still waiting
const waiter = this.waiters.get(key);
if (waiter) {
this.waiters.delete(key);
deferred.reject(new Error(`Panel ${key} registration timed out after ${timeout}ms`));
}
}, timeout);
this.waiters.set(key, { deferred, timeoutId });
return deferred.promise;
};
getPanelKey = (tab: TabName, panelId: string): string => {
return `${tab}:${panelId}`;
};
/**
* Focus a specific panel in a specific tab
* Automatically switches to the target tab if specified
* @param tab - The tab to switch to
* @param panelId - The panel ID to focus
* @returns Promise that resolves to true if successful, false otherwise
*/
focusPanelInTab(tabName: TabName, panelId: string, switchTab = true): boolean {
const apis = this.getTabPanelApis(tabName);
if (!apis) {
log.warn(`Tab "${tabName}" not registered`);
return false;
focusPanel = async (tab: TabName, panelId: string): Promise<boolean> => {
if (!PANEL_ENABLED_TABS.includes(tab)) {
log.error(`Tab ${tab} is not enabled for panel registration`);
return Promise.resolve(false);
}
if (switchTab) {
// Switch to target tab first
if (!this.switchToTab(tabName)) {
try {
// Switch to the target tab if needed
if (this.setAppTab && this.getAppTab && this.getAppTab() !== tab) {
this.setAppTab(tab);
}
// Wait for the panel to be ready
await this.waitForPanel(tab, panelId);
const key = this.getPanelKey(tab, panelId);
const panel = this.panels.get(key);
if (!panel) {
log.error(`Panel ${key} not found after waiting`);
return false;
}
}
// Try to focus in main panel (dockview) first
if (apis.main) {
const panel = apis.main.getPanel(panelId);
if (panel) {
panel.api.setActive();
return true;
}
}
// Focus the panel
panel.api.setActive();
log.debug(`Focused panel ${key}`);
// Try left panel
if (apis.left) {
const panel = apis.left.getPanel(panelId);
if (panel) {
panel.api.setActive();
return true;
}
}
// Try right panel
if (apis.right) {
const panel = apis.right.getPanel(panelId);
if (panel) {
panel.api.setActive();
return true;
}
}
log.warn(`Panel "${panelId}" not found in tab "${tabName}"`);
return false;
}
/**
* Focus a panel in the currently active tab
*/
focusPanelInActiveTab(panelId: string): boolean {
if (!this.tabApi) {
return true;
} catch (error) {
log.error(`Failed to focus panel ${panelId} in tab ${tab}`);
return false;
}
};
const activeTab = this.tabApi.getTab();
return this.focusPanelInTab(activeTab, panelId);
}
focusPanelInActiveTab = (panelId: string): Promise<boolean> => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
if (!activeTab) {
log.error('No active tab found');
return Promise.resolve(false);
}
return this.focusPanel(activeTab, panelId);
};
expandPanel(panel: IGridviewPanel, width: number) {
expandPanel = (panel: IGridviewPanel, width: number) => {
panel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: width });
panel.api.setSize({ width: width });
}
};
collapsePanel(panel: IGridviewPanel) {
collapsePanel = (panel: IGridviewPanel) => {
panel.api.setConstraints({ maximumWidth: 0, minimumWidth: 0 });
panel.api.setSize({ width: 0 });
}
};
/**
* Toggle the left panel in a specific tab
*/
toggleLeftPanelInTab(tabName: TabName): boolean {
const apis = this.getTabPanelApis(tabName);
if (!apis?.root) {
log.warn(`Root panel API not available for tab "${tabName}"`);
getPanel = (tab: TabName, panelId: string): PanelType | undefined => {
if (!PANEL_ENABLED_TABS.includes(tab)) {
log.warn(`Tab ${tab} is not enabled for panel registration`);
return undefined;
}
const key = this.getPanelKey(tab, panelId);
return this.panels.get(key);
};
toggleLeftPanel = (): boolean => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
if (!activeTab) {
log.warn('No active tab found to toggle left panel');
return false;
}
if (!this.switchToTab(tabName)) {
return false;
}
const leftPanel = apis.root.getPanel('left');
const leftPanel = this.getPanel(activeTab, 'left');
if (!leftPanel) {
log.warn(`Left panel not found in tab "${tabName}"`);
log.warn(`Left panel not found in active tab "${activeTab}"`);
return false;
}
if (!(leftPanel instanceof GridviewPanel)) {
log.error(`Right panels must be instances of GridviewPanel`);
return false;
}
@@ -217,25 +202,22 @@ export class AppNavigationApi {
this.collapsePanel(leftPanel);
}
return true;
}
};
/**
* Toggle the right panel in a specific tab
*/
toggleRightPanelInTab(tabName: TabName): boolean {
const apis = this.getTabPanelApis(tabName);
if (!apis?.root) {
log.warn(`Root panel API not available for tab "${tabName}"`);
toggleRightPanel = (): boolean => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;
}
if (!this.switchToTab(tabName)) {
return false;
}
const rightPanel = apis.root.getPanel('right');
const rightPanel = this.getPanel(activeTab, 'right');
if (!rightPanel) {
log.warn(`Right panel not found in tab "${tabName}"`);
log.warn(`Right panel not found in active tab "${activeTab}"`);
return false;
}
if (!(rightPanel instanceof GridviewPanel)) {
log.error(`Right panels must be instances of GridviewPanel`);
return false;
}
@@ -246,19 +228,24 @@ export class AppNavigationApi {
this.collapsePanel(rightPanel);
}
return true;
}
};
toggleBothPanelsInTab(tabName: TabName): boolean {
const apis = this.getTabPanelApis(tabName);
if (!apis?.root) {
log.warn(`Root panel API not available for tab "${tabName}"`);
toggleLeftAndRightPanels = (): boolean => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;
}
const leftPanel = this.getPanel(activeTab, 'left');
const rightPanel = this.getPanel(activeTab, 'right');
if (!rightPanel || !leftPanel) {
log.warn(`Right and/or left panel not found in tab "${activeTab}"`);
return false;
}
const rightPanel = apis.root.getPanel('right');
const leftPanel = apis.root.getPanel('left');
if (!rightPanel || !leftPanel) {
log.warn(`Right and/or left panel not found in tab "${tabName}"`);
if (!(leftPanel instanceof GridviewPanel) || !(rightPanel instanceof GridviewPanel)) {
log.error(`Left and right panels must be instances of GridviewPanel`);
return false;
}
@@ -273,75 +260,90 @@ export class AppNavigationApi {
this.collapsePanel(rightPanel);
}
return true;
}
/**
* Toggle the left panel in the currently active tab
*/
toggleLeftPanelInActiveTab(): boolean {
if (!this.tabApi) {
return false;
}
const activeTab = this.tabApi.getTab();
return this.toggleLeftPanelInTab(activeTab);
}
/**
* Toggle the right panel in the currently active tab
*/
toggleRightPanelInActiveTab(): boolean {
if (!this.tabApi) {
return false;
}
const activeTab = this.tabApi.getTab();
return this.toggleRightPanelInTab(activeTab);
}
};
/**
* Reset panels in a specific tab (expand both left and right)
*/
resetPanelsInTab(tabName: TabName): boolean {
const apis = this.getTabPanelApis(tabName);
if (!apis?.root) {
log.warn(`Root panel API not available for tab "${tabName}"`);
resetLeftAndRightPanels = (): boolean => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;
}
const leftPanel = this.getPanel(activeTab, 'left');
const rightPanel = this.getPanel(activeTab, 'right');
if (!rightPanel || !leftPanel) {
log.warn(`Right and/or left panel not found in tab "${activeTab}"`);
return false;
}
if (!this.switchToTab(tabName)) {
if (!(leftPanel instanceof GridviewPanel) || !(rightPanel instanceof GridviewPanel)) {
log.error(`Left and right panels must be instances of GridviewPanel`);
return false;
}
const rootApi = apis.root as GridviewApi;
const leftPanel = rootApi.getPanel('left');
const rightPanel = rootApi.getPanel('right');
leftPanel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: LEFT_PANEL_MIN_SIZE_PX });
leftPanel.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
if (leftPanel) {
leftPanel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: LEFT_PANEL_MIN_SIZE_PX });
leftPanel.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
}
if (rightPanel) {
rightPanel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: RIGHT_PANEL_MIN_SIZE_PX });
rightPanel.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
}
rightPanel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: RIGHT_PANEL_MIN_SIZE_PX });
rightPanel.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
return true;
}
};
/**
* Reset panels in the currently active tab
* Check if a panel is registered
* @param tab - The tab the panel belongs to
* @param panelId - The panel ID to check
* @returns True if the panel is registered
*/
resetPanelsInActiveTab(): boolean {
if (!this.tabApi) {
return false;
isPanelRegistered = (tab: TabName, panelId: string): boolean => {
const key = `${tab}:${panelId}`;
return this.panels.has(key);
};
/**
* Get all registered panels for a tab
* @param tab - The tab to get panels for
* @returns Array of panel IDs
*/
getRegisteredPanels = (tab: TabName): string[] => {
const prefix = `${tab}:`;
return Array.from(this.panels.keys())
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length));
};
/**
* Unregister all panels for a tab
* @param tab - The tab to unregister panels for
*/
unregisterTab = (tab: TabName): void => {
const prefix = `${tab}:`;
const keysToDelete = Array.from(this.panels.keys()).filter((key) => key.startsWith(prefix));
for (const key of keysToDelete) {
this.panels.delete(key);
}
const activeTab = this.tabApi.getTab();
return this.resetPanelsInTab(activeTab);
}
// Clean up any pending promises by rejecting them
const promiseKeysToDelete = Array.from(this.waiters.keys()).filter((key) => key.startsWith(prefix));
for (const key of promiseKeysToDelete) {
const waiter = this.waiters.get(key);
if (waiter) {
// Clear timeout before rejecting
if (waiter.timeoutId) {
clearTimeout(waiter.timeoutId);
}
waiter.deferred.reject(new Error(`Panel registration cancelled - tab ${tab} was unregistered`));
}
this.waiters.delete(key);
}
log.debug(`Unregistered all panels for tab ${tab}`);
};
}
// Global singleton instance
export const navigationApi = new AppNavigationApi();
export const navigationApi = new NavigationApi();

View File

@@ -92,6 +92,10 @@ const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
},
});
// Register panels with navigation API
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
return { launchpad, viewer } satisfies Record<string, IDockviewPanel>;
};
@@ -101,7 +105,6 @@ const MainPanel = memo(() => {
({ api }) => {
const panels = initializeCenterPanelLayout(tab, api);
panels.launchpad.api.setActive();
navigationApi.registerPanel(tab, 'main', api);
const disposables = [
api.onWillShowOverlay((e) => {
@@ -174,6 +177,10 @@ export const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
// Register panels with navigation API
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
};
@@ -183,7 +190,6 @@ const RightPanel = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(
({ api }) => {
initializeRightPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'right', api);
},
[tab]
);
@@ -212,6 +218,9 @@ export const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
},
});
// Register panel with navigation API
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
return { settings } satisfies Record<string, IGridviewPanel>;
};
@@ -221,7 +230,6 @@ const LeftPanel = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(
({ api }) => {
initializeLeftPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'left', api);
},
[tab]
);
@@ -273,6 +281,10 @@ export const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
navigationApi.registerPanel('upscaling', LEFT_PANEL_ID, left);
navigationApi.registerPanel('upscaling', MAIN_PANEL_ID, main);
navigationApi.registerPanel('upscaling', RIGHT_PANEL_ID, right);
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
@@ -290,8 +302,9 @@ export const UpscalingTabAutoLayout = memo(() => {
return;
}
initializeRootPanelLayout(rootApi);
navigationApi.registerPanel('upscaling', 'root', rootApi);
navigationApi.focusPanelInTab('upscaling', LAUNCHPAD_PANEL_ID, false);
// Focus the launchpad panel once it's ready
navigationApi.focusPanel('upscaling', LAUNCHPAD_PANEL_ID);
return () => {
navigationApi.unregisterTab('upscaling');

View File

@@ -1,9 +1,8 @@
import type { GridviewPanelApi, IGridviewPanel } from 'dockview';
import { GridviewPanel, type GridviewPanelApi, type IGridviewPanel } from 'dockview';
import type { TabName } from 'features/ui/store/uiTypes';
import { atom } from 'nanostores';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { TabPanelApis } from './navigation-api';
import { navigationApi } from './navigation-api';
const getIsCollapsed = (
@@ -19,7 +18,6 @@ const getIsCollapsed = (
export const useCollapsibleGridviewPanel = (
tab: TabName,
rootPanelId: Exclude<keyof TabPanelApis, 'main'>,
panelId: string,
orientation: 'horizontal' | 'vertical',
defaultSize: number,
@@ -28,12 +26,9 @@ export const useCollapsibleGridviewPanel = (
const $isCollapsed = useState(() => atom(false))[0];
const lastExpandedSizeRef = useRef<number>(0);
const collapse = useCallback(() => {
const api = navigationApi.getTabPanelApis(tab)?.[rootPanelId];
if (!api) {
return;
}
const panel = api.getPanel(panelId);
if (!panel) {
const panel = navigationApi.getPanel(tab, panelId);
if (!panel || !(panel instanceof GridviewPanel)) {
return;
}
@@ -44,16 +39,11 @@ export const useCollapsibleGridviewPanel = (
} else {
panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth });
}
}, [collapsedSize, orientation, panelId, rootPanelId, tab]);
}, [collapsedSize, orientation, panelId, tab]);
const expand = useCallback(() => {
const api = navigationApi.getTabPanelApis(tab)?.[rootPanelId];
if (!api) {
return;
}
const panel = api.getPanel(panelId);
if (!panel) {
const panel = navigationApi.getPanel(tab, panelId);
if (!panel || !(panel instanceof GridviewPanel)) {
return;
}
if (orientation === 'vertical') {
@@ -61,16 +51,11 @@ export const useCollapsibleGridviewPanel = (
} else {
panel.api.setSize({ width: lastExpandedSizeRef.current || defaultSize });
}
}, [defaultSize, orientation, panelId, rootPanelId, tab]);
}, [defaultSize, orientation, panelId, tab]);
const toggle = useCallback(() => {
const api = navigationApi.getTabPanelApis(tab)?.[rootPanelId];
if (!api) {
return;
}
const panel = api.getPanel(panelId);
if (!panel) {
const panel = navigationApi.getPanel(tab, panelId);
if (!panel || !(panel instanceof GridviewPanel)) {
return;
}
const isCollapsed = getIsCollapsed(panel, orientation, collapsedSize);
@@ -79,16 +64,11 @@ export const useCollapsibleGridviewPanel = (
} else {
collapse();
}
}, [tab, rootPanelId, panelId, orientation, collapsedSize, expand, collapse]);
}, [tab, panelId, orientation, collapsedSize, expand, collapse]);
useEffect(() => {
const api = navigationApi.getTabPanelApis(tab)?.[rootPanelId];
if (!api) {
return;
}
const panel = api.getPanel(panelId);
if (!panel) {
const panel = navigationApi.getPanel(tab, panelId);
if (!panel || !(panel instanceof GridviewPanel)) {
return;
}
@@ -100,7 +80,7 @@ export const useCollapsibleGridviewPanel = (
return () => {
disposable.dispose();
};
}, [$isCollapsed, collapsedSize, orientation, panelId, rootPanelId, tab]);
}, [$isCollapsed, collapsedSize, orientation, panelId, tab]);
return useMemo(
() => ({

View File

@@ -3,10 +3,9 @@ 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 { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect } 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.
@@ -14,21 +13,6 @@ import { navigationApi as navigationApi2 } from './navigation-api-2';
export const useNavigationApi = () => {
useAssertSingleton('useNavigationApi');
const store = useAppStore();
const tabApi = useMemo(
() => ({
getTab: () => {
return selectActiveTab(store.getState());
},
setTab: (tab: TabName) => {
store.dispatch(setActiveTab(tab));
},
}),
[store]
);
useEffect(() => {
navigationApi.setTabApi(tabApi);
}, [store, tabApi]);
const getAppTab = useCallback(() => {
return selectActiveTab(store.getState());
@@ -40,6 +24,6 @@ export const useNavigationApi = () => {
[store]
);
useEffect(() => {
navigationApi2.connectToApp({ getAppTab, setAppTab });
}, [getAppTab, setAppTab, store, tabApi]);
navigationApi.connectToApp({ getAppTab, setAppTab });
}, [getAppTab, setAppTab, store]);
};

View File

@@ -110,6 +110,11 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
},
});
// Register panels with navigation API
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
return { launchpad, workspace, viewer } satisfies Record<string, IDockviewPanel>;
};
@@ -119,7 +124,6 @@ const MainPanel = memo(() => {
const onReady = useCallback<IDockviewReactProps['onReady']>(
({ api }) => {
const panels = initializeMainPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'main', api);
panels.launchpad.api.setActive();
const disposables = [
@@ -193,6 +197,10 @@ export const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => {
gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX, width: RIGHT_PANEL_MIN_SIZE_PX });
// Register panels with navigation API
navigationApi.registerPanel(tab, GALLERY_PANEL_ID, gallery);
navigationApi.registerPanel(tab, BOARDS_PANEL_ID, boards);
return { gallery, boards } satisfies Record<string, IGridviewPanel>;
};
@@ -202,7 +210,6 @@ const RightPanel = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(
({ api }) => {
initializeRightPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'right', api);
},
[tab]
);
@@ -231,6 +238,9 @@ export const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => {
},
});
// Register panel with navigation API
navigationApi.registerPanel(tab, SETTINGS_PANEL_ID, settings);
return { settings } satisfies Record<string, IGridviewPanel>;
};
@@ -240,7 +250,6 @@ const LeftPanel = memo(() => {
const onReady = useCallback<IGridviewReactProps['onReady']>(
({ api }) => {
initializeLeftPanelLayout(tab, api);
navigationApi.registerPanel(tab, 'left', api);
},
[tab]
);
@@ -285,9 +294,14 @@ export const initializeRootPanelLayout = (api: GridviewApi) => {
referencePanel: MAIN_PANEL_ID,
},
});
left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX });
right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX });
navigationApi.registerPanel('workflows', LEFT_PANEL_ID, left);
navigationApi.registerPanel('workflows', MAIN_PANEL_ID, main);
navigationApi.registerPanel('workflows', RIGHT_PANEL_ID, right);
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
@@ -305,8 +319,9 @@ export const WorkflowsTabAutoLayout = memo(() => {
return;
}
initializeRootPanelLayout(rootApi);
navigationApi.registerPanel('workflows', 'root', rootApi);
navigationApi.focusPanelInTab('workflows', LAUNCHPAD_PANEL_ID, false);
// Focus the launchpad panel once it's ready
navigationApi.focusPanel('workflows', LAUNCHPAD_PANEL_ID);
return () => {
navigationApi.unregisterTab('workflows');

View File

@@ -1,6 +1,8 @@
import { logger } from 'app/logging/logger';
import type { AppDispatch } from 'app/store/store';
import { Mutex } from 'async-mutex';
import type { Deferred } from 'common/util/createDeferredPromise';
import { createDeferredPromise } from 'common/util/createDeferredPromise';
import { withResultAsync, WrappedError } from 'common/util/result';
import { parseify } from 'common/util/serialize';
import { getPrefixedId } from 'features/controlLayers/konva/util';
@@ -15,27 +17,6 @@ import type { EnqueueBatchArg } from './types';
const log = logger('system');
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
};
/**
* Create a promise and expose its resolve and reject callbacks.
*/
const createDeferredPromise = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
type QueueStatusEventHandler = {
subscribe: (handler: (event: S['QueueItemStatusChangedEvent']) => void) => void;
unsubscribe: (handler: (event: S['QueueItemStatusChangedEvent']) => void) => void;