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 new file mode 100644 index 0000000000..6d7730a9ef --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.test.ts @@ -0,0 +1,305 @@ +import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GenerateTabLayout } from './navigation-api-2'; +import { AppNavigationApi } from './navigation-api-2'; + +// Mock the logger +vi.mock('app/logging/logger', () => ({ + logger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +// Mock panel with setActive method +const createMockPanel = () => + ({ + api: { + setActive: vi.fn(), + }, + // Add other required properties as needed + }) as unknown as IGridviewPanel; + +const createMockDockPanel = () => + ({ + api: { + setActive: vi.fn(), + }, + // Add other required properties as needed + }) as unknown as IDockviewPanel; + +// Create a mock layout for testing +const createMockGenerateLayout = (): GenerateTabLayout => ({ + gridviewApi: {} as Readonly, + 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 new file mode 100644 index 0000000000..07ac82b6f1 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api-2.ts @@ -0,0 +1,235 @@ +import { logger } from 'app/logging/logger'; +import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview'; +import type { TabName } from 'features/ui/store/uiTypes'; + +const log = logger('system'); + +export type GenerateTabLayout = { + gridviewApi: Readonly; + 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/use-navigation-api.tsx b/invokeai/frontend/web/src/features/ui/layouts/use-navigation-api.tsx index 1cbc29e8e2..7bc0cd5e31 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,9 +3,10 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { setActiveTab } from 'features/ui/store/uiSlice'; import type { TabName } from 'features/ui/store/uiTypes'; -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { navigationApi } from './navigation-api'; +import { navigationApi as navigationApi2 } from './navigation-api-2'; /** * Hook that initializes the global navigation API with callbacks to access and modify the active tab. @@ -28,4 +29,17 @@ export const useNavigationApi = () => { useEffect(() => { navigationApi.setTabApi(tabApi); }, [store, tabApi]); + + const getAppTab = useCallback(() => { + return selectActiveTab(store.getState()); + }, [store]); + const setAppTab = useCallback( + (tab: TabName) => { + store.dispatch(setActiveTab(tab)); + }, + [store] + ); + useEffect(() => { + navigationApi2.connectToApp({ getAppTab, setAppTab }); + }, [getAppTab, setAppTab, store, tabApi]); };