From bcced8a5e86c22dd4244a5230f980c60748d1faf Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:40:05 +1000 Subject: [PATCH] refactor(ui): navigation api --- .../src/common/util/createDeferredPromise.ts | 20 + .../SimpleSession/CanvasLaunchpadPanel.tsx | 2 +- .../components/BoardsListPanelContent.tsx | 1 - .../features/gallery/components/Gallery.tsx | 1 - ...ImageMenuItemNewCanvasFromImageSubMenu.tsx | 8 +- .../ImageMenuItemNewLayerFromImageSubMenu.tsx | 10 +- .../ImageViewer/CurrentImageButtons.tsx | 2 +- .../WorkflowViewEditToggleButton.tsx | 4 +- .../web/src/features/queue/hooks/useInvoke.ts | 8 +- .../components/FloatingLeftPanelButtons.tsx | 13 +- .../components/FloatingRightPanelButtons.tsx | 13 +- .../ui/layouts/PanelHotkeysLogical.tsx | 36 +- .../ui/layouts/canvas-tab-auto-layout.tsx | 25 +- .../ui/layouts/generate-tab-auto-layout.tsx | 23 +- .../ui/layouts/navigation-api-2.test.ts | 305 ----------- .../features/ui/layouts/navigation-api-2.ts | 235 --------- .../ui/layouts/navigation-api.test.ts | 368 ++++++++++++++ .../src/features/ui/layouts/navigation-api.ts | 480 +++++++++--------- .../ui/layouts/upscaling-tab-auto-layout.tsx | 23 +- .../layouts/use-collapsible-gridview-panel.ts | 48 +- .../ui/layouts/use-navigation-api.tsx | 22 +- .../ui/layouts/workflows-tab-auto-layout.tsx | 25 +- .../web/src/services/api/run-graph.ts | 23 +- 23 files changed, 749 insertions(+), 946 deletions(-) create mode 100644 invokeai/frontend/web/src/common/util/createDeferredPromise.ts delete mode 100644 invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.test.ts delete mode 100644 invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.ts create mode 100644 invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts diff --git a/invokeai/frontend/web/src/common/util/createDeferredPromise.ts b/invokeai/frontend/web/src/common/util/createDeferredPromise.ts new file mode 100644 index 0000000000..b82bb795cc --- /dev/null +++ b/invokeai/frontend/web/src/common/util/createDeferredPromise.ts @@ -0,0 +1,20 @@ +export type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +}; + +/** + * Create a promise and expose its resolve and reject callbacks. + */ +export const createDeferredPromise = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx index a5f091c9e8..4b41048bf7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx @@ -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 ( diff --git a/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx b/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx index c9da0d52d3..d6442b8ef8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx @@ -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, diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 5b8748b25a..5eaffb94dc 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -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, diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx index 8d8e25552e..a1a9d64e7f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx @@ -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'), diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx index fb5571a121..22966a8273 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx @@ -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'), diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index 4c1b3d7517..05042c050f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -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) { diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowViewEditToggleButton.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowViewEditToggleButton.tsx index e1f22899d7..c6221072e1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowViewEditToggleButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowViewEditToggleButton.tsx @@ -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] ); diff --git a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts index 4c445c878e..ba32c41e3d 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts @@ -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]); diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx index b38b30f635..81e8930e40 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx @@ -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 ( } flexGrow={1} /> diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx index ad6b984773..dc0d8025fa 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx @@ -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 ( } h={48} /> diff --git a/invokeai/frontend/web/src/features/ui/layouts/PanelHotkeysLogical.tsx b/invokeai/frontend/web/src/features/ui/layouts/PanelHotkeysLogical.tsx index 82f0cc72a7..5e0b35fd55 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/PanelHotkeysLogical.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/PanelHotkeysLogical.tsx @@ -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; diff --git a/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx index 7e421ae086..27f78fd49d 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx @@ -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; }; @@ -122,7 +127,6 @@ const MainPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -220,7 +229,6 @@ const RightPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -258,7 +269,6 @@ const LeftPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -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'); diff --git a/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx index 4c30520c7a..1cd6e1eab6 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx @@ -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; }; @@ -101,7 +105,6 @@ const MainPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -184,7 +191,6 @@ const RightPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -222,7 +231,6 @@ const LeftPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -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'); diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.test.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.test.ts deleted file mode 100644 index 6d7730a9ef..0000000000 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.test.ts +++ /dev/null @@ -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, - panels: { - left: { - panelApi: {} as Readonly, - gridviewApi: {} as Readonly, - panels: { - settings: createMockPanel(), - }, - }, - main: { - panelApi: {} as Readonly, - dockviewApi: {} as Readonly, - panels: { - launchpad: createMockDockPanel(), - viewer: createMockDockPanel(), - }, - }, - right: { - panelApi: {} as Readonly, - gridviewApi: {} as Readonly, - panels: { - boards: createMockPanel(), - gallery: createMockPanel(), - }, - }, - }, -}); - -describe('AppNavigationApi', () => { - let navigationApi: AppNavigationApi; - let mockSetAppTab: ReturnType; - let mockGetAppTab: ReturnType; - - 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(); - }); - }); -}); diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.ts deleted file mode 100644 index 07ac82b6f1..0000000000 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.ts +++ /dev/null @@ -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; - panels: { - left: { - panelApi: Readonly; - gridviewApi: Readonly; - panels: { - settings: Readonly; - }; - }; - main: { - panelApi: Readonly; - dockviewApi: Readonly; - panels: { - launchpad: Readonly; - viewer: Readonly; - }; - }; - right: { - panelApi: Readonly; - gridviewApi: Readonly; - panels: { - boards: Readonly; - gallery: Readonly; - }; - }; - }; -}; - -export type CanvasTabLayout = { - gridviewApi: Readonly; - panels: { - left: { - panelApi: Readonly; - gridviewApi: Readonly; - panels: { - settings: Readonly; - }; - }; - main: { - panelApi: Readonly; - dockviewApi: Readonly; - panels: { - launchpad: Readonly; - workspace: Readonly; - viewer: Readonly; - }; - }; - right: { - panelApi: Readonly; - gridviewApi: Readonly; - panels: { - boards: Readonly; - gallery: Readonly; - layers: Readonly; - }; - }; - }; -}; - -export type UpscalingTabLayout = { - gridviewApi: Readonly; - panels: { - left: { - panelApi: Readonly; - gridviewApi: Readonly; - panels: { - settings: Readonly; - }; - }; - main: { - panelApi: Readonly; - dockviewApi: Readonly; - panels: { - launchpad: Readonly; - viewer: Readonly; - }; - }; - right: { - panelApi: Readonly; - gridviewApi: Readonly; - panels: { - boards: Readonly; - gallery: Readonly; - }; - }; - }; -}; - -export type WorkflowsTabLayout = { - gridviewApi: Readonly; - panels: { - left: { - panelApi: Readonly; - gridviewApi: Readonly; - panels: { - settings: Readonly; - }; - }; - main: { - panelApi: Readonly; - dockviewApi: Readonly; - panels: { - launchpad: Readonly; - workspace: Readonly; - viewer: Readonly; - }; - }; - right: { - panelApi: Readonly; - gridviewApi: Readonly; - panels: { - boards: Readonly; - gallery: Readonly; - }; - }; - }; -}; - -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 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(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['panels'], - P extends keyof (NonNullable['panels'][R] extends { panels: infer Panels } ? Panels : never), - >(tabName: T, rootPanelId: R, panelId: P): Promise { - 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(tabName: T, timeout = 1000): Promise { - 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(); diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts new file mode 100644 index 0000000000..7ffcbdd065 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts @@ -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; + let mockGetAppTab: ReturnType; + + 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(); + }); + }); +}); diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts index a62ab50183..ddc9384b16 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -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 | null; - left: Readonly | null; - main: Readonly | null; - right: Readonly | null; +type PanelType = IGridviewPanel | IDockviewPanel; + +type Waiter = { + deferred: Deferred; + timeoutId: ReturnType | 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 = new Map(); + private waiters: Map = new Map(); -/** - * Global API for managing application navigation and tab panels. - */ -export class AppNavigationApi { - private tabPanelApis = new Map(); + 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(tab: TabName, panel: T, api: NonNullable): 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(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 => { + 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(); + + 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 => { + 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 => { + 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(); diff --git a/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx index 3d04b010fe..dbc9771c2a 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/upscaling-tab-auto-layout.tsx @@ -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; }; @@ -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; }; @@ -183,7 +190,6 @@ const RightPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -221,7 +230,6 @@ const LeftPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -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'); diff --git a/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts b/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts index 2b4b70df3b..c67444346a 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts @@ -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, panelId: string, orientation: 'horizontal' | 'vertical', defaultSize: number, @@ -28,12 +26,9 @@ export const useCollapsibleGridviewPanel = ( const $isCollapsed = useState(() => atom(false))[0]; const lastExpandedSizeRef = useRef(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( () => ({ diff --git a/invokeai/frontend/web/src/features/ui/layouts/use-navigation-api.tsx b/invokeai/frontend/web/src/features/ui/layouts/use-navigation-api.tsx index 7bc0cd5e31..9f7731e182 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/use-navigation-api.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/use-navigation-api.tsx @@ -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]); }; diff --git a/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx index 7f886780eb..6003c2b650 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/workflows-tab-auto-layout.tsx @@ -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; }; @@ -119,7 +124,6 @@ const MainPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -202,7 +210,6 @@ const RightPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -240,7 +250,6 @@ const LeftPanel = memo(() => { const onReady = useCallback( ({ 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; }; @@ -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'); diff --git a/invokeai/frontend/web/src/services/api/run-graph.ts b/invokeai/frontend/web/src/services/api/run-graph.ts index 4fcbe4e314..7179bd0832 100644 --- a/invokeai/frontend/web/src/services/api/run-graph.ts +++ b/invokeai/frontend/web/src/services/api/run-graph.ts @@ -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 = { - promise: Promise; - resolve: (value: T) => void; - reject: (error: Error) => void; -}; - -/** - * Create a promise and expose its resolve and reject callbacks. - */ -const createDeferredPromise = (): Deferred => { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - - const promise = new Promise((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;