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 index 8a7c1c814a..e0d666d842 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts @@ -436,7 +436,7 @@ describe('AppNavigationApi', () => { const mockPanel = createMockPanel(); const width = 500; - navigationApi.expandPanel(mockPanel, width); + navigationApi._expandPanel(mockPanel, width); expect(mockPanel.api.setConstraints).toHaveBeenCalledWith({ maximumWidth: Number.MAX_SAFE_INTEGER, @@ -448,7 +448,7 @@ describe('AppNavigationApi', () => { it('should collapse panel with zero constraints and size', () => { const mockPanel = createMockPanel(); - navigationApi.collapsePanel(mockPanel); + navigationApi._collapsePanel(mockPanel); expect(mockPanel.api.setConstraints).toHaveBeenCalledWith({ maximumWidth: 0, 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 3dc99cc590..2630a4bd7b 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -16,34 +16,82 @@ const log = logger('system'); type PanelType = IGridviewPanel | IDockviewPanel; +/** + * An object that represents a promise that is waiting for a panel to be registered and ready. + * + * It includes a deferred promise that can be resolved or rejected, and a timeout ID. + */ type Waiter = { deferred: Deferred; timeoutId: ReturnType | null; }; export class NavigationApi { + /** + * Map of registered panels, keyed by tab and panel ID in this format: + * `${tab}:${panelId}` + */ private panels: Map = new Map(); + + /** + * Map of waiters for panel registration. + */ private waiters: Map = new Map(); + /** + * A flag indicating if the application is currently switching tabs, which can take some time. + */ $isSwitchingTabs = atom(false); + /** + * The timeout used to add a short additional delay when switching tabs. + * + * The time it takes to switch tabs varies depending on the tab, and sometimes it is very fast, resulting in a flicker + * of the loading screen. This timeout is used to artificially extend the time the loading screen is shown. + */ switchingTabsTimeout: ReturnType | null = null; + /** + * Separator used to create unique keys for panels. Typo protection. + */ KEY_SEPARATOR = ':'; + /** + * Private imperative method to set the current app tab. + */ _setAppTab: ((tab: TabName) => void) | null = null; + + /** + * Private imperative method to get the current app tab. + */ _getAppTab: (() => TabName) | null = null; + /** + * Connect to the application to manage tab switching. + * @param arg.setAppTab - Function to set the current app tab + * @param arg.getAppTab - Function to get the current app tab + */ connectToApp = (arg: { setAppTab: (tab: TabName) => void; getAppTab: () => TabName }): void => { const { setAppTab, getAppTab } = arg; this._setAppTab = setAppTab; this._getAppTab = getAppTab; }; + /** + * Disconnect from the application, clearing the tab management functions. + */ disconnectFromApp = (): void => { this._setAppTab = null; this._getAppTab = null; }; + /** + * Switch to a specific app tab. + * + * The loading screen will be shown while the tab is switching. + * + * @param tab - The tab to switch to + * @return True if the switch was successful, false otherwise + */ switchToTab = (tab: TabName): boolean => { if (this.switchingTabsTimeout !== null) { clearTimeout(this.switchingTabsTimeout); @@ -63,30 +111,35 @@ export class NavigationApi { } }; - onSwitchedTab = (): void => { - log.debug('Tab switch completed'); + /** + * Callback for when a tab is ready after switching. + * + * Hides the loading screen after a short delay. + */ + onTabReady = (tab: TabName): void => { this.switchingTabsTimeout = setTimeout(() => { this.$isSwitchingTabs.set(false); + log.debug(`Tab ${tab} ready`); }, SWITCH_TABS_FAKE_DELAY_MS); }; /** - * Register a panel with a unique ID + * Registers a panel with the navigation API. + * * @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 */ registerPanel = (tab: TabName, panelId: string, panel: PanelType): (() => void) => { - const key = this.getPanelKey(tab, panelId); + const key = this._getPanelKey(tab, panelId); this.panels.set(key, panel); - // Resolve any waiting promises + // Resolve any pending waiters for this panel, notifying them that the panel is now registered. const waiter = this.waiters.get(key); if (waiter) { if (waiter.timeoutId) { - // Clear the timeout if it exists clearTimeout(waiter.timeoutId); } waiter.deferred.resolve(); @@ -102,30 +155,42 @@ export class NavigationApi { }; /** - * Wait for a panel to be ready + * Waits 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 + * @returns Promise that resolves when the panel is ready or rejects if it times out + * + * @example + * ```typescript + * try { + * await navigationApi.waitForPanel('myTab', 'myPanelId'); + * console.log('Panel is ready'); + * } catch (error) { + * console.error('Panel registration timed out:', error); + * } + * ``` */ waitForPanel = (tab: TabName, panelId: string, timeout = 2000): Promise => { - const key = this.getPanelKey(tab, panelId); + const key = this._getPanelKey(tab, panelId); + // If the panel is already registered, we can resolve immediately. if (this.panels.has(key)) { return Promise.resolve(); } - // Check if we already have a promise for this panel + // If we already have a waiter for this panel, return its promise instead of creating a new one. const existing = this.waiters.get(key); - if (existing) { return existing.deferred.promise; } + // We do not have any waiters; create one and set up the timeout. const deferred = createDeferredPromise(); const timeoutId = setTimeout(() => { - // Only reject if this deferred is still waiting + // If the timeout expires, reject the promise and clean up the waiter. const waiter = this.waiters.get(key); if (waiter) { this.waiters.delete(key); @@ -137,29 +202,47 @@ export class NavigationApi { return deferred.promise; }; - getTabPrefix = (tab: TabName): string => { + /** + * Get the prefix for a tab to create unique keys for panels. + */ + _getTabPrefix = (tab: TabName): string => { return `${tab}${this.KEY_SEPARATOR}`; }; - getPanelKey = (tab: TabName, panelId: string): string => { - return `${this.getTabPrefix(tab)}${panelId}`; + /** + * Get the unique key for a panel based on its tab and ID. + */ + _getPanelKey = (tab: TabName, panelId: string): string => { + return `${this._getTabPrefix(tab)}${panelId}`; }; /** - * Focus a specific panel in a specific tab + * Focuses a specific panel in a specific tab. + * + * This method does not throw; it returns a Promise that resolves to true if the panel was successfully focused, + * or false if it failed to focus the panel (e.g., if the panel was not found or the tab switch failed). + * * @param tab - The tab to switch to * @param panelId - The panel ID to focus + * @param timeout - Timeout in milliseconds (default: 2000) * @returns Promise that resolves to true if successful, false otherwise + * + * @example + * ```typescript + * const focused = await navigationApi.focusPanel('myTab', 'myPanelId'); + * if (focused) { + * console.log('Panel focused successfully'); + * } else { + * console.error('Failed to focus panel'); + * } + * ``` */ - focusPanel = async (tab: TabName, panelId: string): Promise => { + focusPanel = async (tab: TabName, panelId: string, timeout = 2000): Promise => { try { - // Switch to the target tab if needed this.switchToTab(tab); + await this.waitForPanel(tab, panelId, timeout); - // Wait for the panel to be ready - await this.waitForPanel(tab, panelId); - - const key = this.getPanelKey(tab, panelId); + const key = this._getPanelKey(tab, panelId); const panel = this.panels.get(key); if (!panel) { @@ -167,7 +250,7 @@ export class NavigationApi { return false; } - // Focus the panel + // Dockview uses the term "active", but we use "focused" for consistency. panel.api.setActive(); log.debug(`Focused panel ${key}`); @@ -178,30 +261,70 @@ export class NavigationApi { } }; - focusPanelInActiveTab = (panelId: string): Promise => { + /** + * Focuses a specific panel in the currently active tab. + * + * If the panel does not exist in the active tab, it returns false after a timeout. + * + * @param panelId - The panel ID to focus + * @param timeout - Timeout in milliseconds (default: 2000) + * @return Promise that resolves to true if the panel was focused, false otherwise + * + * @example + * ```typescript + * const focused = await navigationApi.focusPanelInActiveTab('myPanelId'); + * if (focused) { + * console.log('Panel focused successfully in active tab'); + * } else { + * console.error('Failed to focus panel in active tab'); + * } + */ + focusPanelInActiveTab = (panelId: string, timeout = 2000): 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); + return this.focusPanel(activeTab, panelId, timeout); }; - expandPanel = (panel: IGridviewPanel, width: number) => { + /** + * Expand a panel to a specified width. + */ + _expandPanel = (panel: IGridviewPanel, width: number) => { panel.api.setConstraints({ maximumWidth: Number.MAX_SAFE_INTEGER, minimumWidth: width }); panel.api.setSize({ width: width }); }; - collapsePanel = (panel: IGridviewPanel) => { + /** + * Collapse a panel by setting its width to 0. + */ + _collapsePanel = (panel: IGridviewPanel) => { panel.api.setConstraints({ maximumWidth: 0, minimumWidth: 0 }); panel.api.setSize({ width: 0 }); }; + /** + * Get a panel by its tab and ID. + * + * This method will not wait for the panel to be registered. + * + * @param tab - The tab the panel belongs to + * @param panelId - The panel ID + * @returns The panel instance or undefined if not found + */ getPanel = (tab: TabName, panelId: string): PanelType | undefined => { - const key = this.getPanelKey(tab, panelId); + const key = this._getPanelKey(tab, panelId); return this.panels.get(key); }; + /** + * Toggle the left panel in the currently active tab. + * + * This method will not wait for the panel to be registered. + * + * @returns True if the panel was toggled, false if it was not found or an error occurred + */ toggleLeftPanel = (): boolean => { const activeTab = this._getAppTab ? this._getAppTab() : null; if (!activeTab) { @@ -221,13 +344,20 @@ export class NavigationApi { const isCollapsed = leftPanel.maximumWidth === 0; if (isCollapsed) { - this.expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX); + this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX); } else { - this.collapsePanel(leftPanel); + this._collapsePanel(leftPanel); } return true; }; + /** + * Toggle the right panel in the currently active tab. + * + * This method will not wait for the panel to be registered. + * + * @returns True if the panel was toggled, false if it was not found or an error occurred + */ toggleRightPanel = (): boolean => { const activeTab = this._getAppTab ? this._getAppTab() : null; if (!activeTab) { @@ -247,13 +377,21 @@ export class NavigationApi { const isCollapsed = rightPanel.maximumWidth === 0; if (isCollapsed) { - this.expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX); + this._expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX); } else { - this.collapsePanel(rightPanel); + this._collapsePanel(rightPanel); } return true; }; + /** + * Toggle the left and right panels in the currently active tab. + * + * This method will not wait for the panels to be registered. If either panel is not found, it will not toggle + * either panel. + * + * @returns True if the panels were toggled, false if they were not found or an error occurred + */ toggleLeftAndRightPanels = (): boolean => { const activeTab = this._getAppTab ? this._getAppTab() : null; if (!activeTab) { @@ -277,17 +415,22 @@ export class NavigationApi { const isRightCollapsed = rightPanel.maximumWidth === 0; if (isLeftCollapsed || isRightCollapsed) { - this.expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX); - this.expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX); + this._expandPanel(leftPanel, LEFT_PANEL_MIN_SIZE_PX); + this._expandPanel(rightPanel, RIGHT_PANEL_MIN_SIZE_PX); } else { - this.collapsePanel(leftPanel); - this.collapsePanel(rightPanel); + this._collapsePanel(leftPanel); + this._collapsePanel(rightPanel); } return true; }; /** - * Reset panels in a specific tab (expand both left and right) + * Reset both left and right panels in the currently active tab to their minimum sizes. + * + * This method will not wait for the panels to be registered. If either panel is not found, it will not reset + * either panel. + * + * @returns True if the panels were reset, false if they were not found or an error occurred */ resetLeftAndRightPanels = (): boolean => { const activeTab = this._getAppTab ? this._getAppTab() : null; @@ -318,46 +461,45 @@ export class NavigationApi { }; /** - * Check if a panel is registered + * 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 */ isPanelRegistered = (tab: TabName, panelId: string): boolean => { - const key = this.getPanelKey(tab, panelId); + const key = this._getPanelKey(tab, panelId); return this.panels.has(key); }; /** - * Get all registered panels for a tab + * 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 = this.getTabPrefix(tab); + const prefix = this._getTabPrefix(tab); return Array.from(this.panels.keys()) .filter((key) => key.startsWith(prefix)) .map((key) => key.substring(prefix.length)); }; /** - * Unregister all panels for a tab + * Unregister all panels for a tab. Any pending waiters for these panels will be rejected. * @param tab - The tab to unregister panels for */ unregisterTab = (tab: TabName): void => { - const prefix = this.getTabPrefix(tab); + const prefix = this._getTabPrefix(tab); const keysToDelete = Array.from(this.panels.keys()).filter((key) => key.startsWith(prefix)); for (const key of keysToDelete) { this.panels.delete(key); } - // 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 + // Clear timeout before rejecting to prevent multiple rejections if (waiter.timeoutId) { clearTimeout(waiter.timeoutId); }