mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-13 18:25:28 -05:00
refactor(ui): panel api (WIP)
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GenerateTabLayout } from './navigation-api-2';
|
||||
import { AppNavigationApi } from './navigation-api-2';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('app/logging/logger', () => ({
|
||||
logger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock panel with setActive method
|
||||
const createMockPanel = () =>
|
||||
({
|
||||
api: {
|
||||
setActive: vi.fn(),
|
||||
},
|
||||
// Add other required properties as needed
|
||||
}) as unknown as IGridviewPanel;
|
||||
|
||||
const createMockDockPanel = () =>
|
||||
({
|
||||
api: {
|
||||
setActive: vi.fn(),
|
||||
},
|
||||
// Add other required properties as needed
|
||||
}) as unknown as IDockviewPanel;
|
||||
|
||||
// Create a mock layout for testing
|
||||
const createMockGenerateLayout = (): GenerateTabLayout => ({
|
||||
gridviewApi: {} as Readonly<GridviewApi>,
|
||||
panels: {
|
||||
left: {
|
||||
panelApi: {} as Readonly<IGridviewPanel>,
|
||||
gridviewApi: {} as Readonly<GridviewApi>,
|
||||
panels: {
|
||||
settings: createMockPanel(),
|
||||
},
|
||||
},
|
||||
main: {
|
||||
panelApi: {} as Readonly<IGridviewPanel>,
|
||||
dockviewApi: {} as Readonly<DockviewApi>,
|
||||
panels: {
|
||||
launchpad: createMockDockPanel(),
|
||||
viewer: createMockDockPanel(),
|
||||
},
|
||||
},
|
||||
right: {
|
||||
panelApi: {} as Readonly<IGridviewPanel>,
|
||||
gridviewApi: {} as Readonly<GridviewApi>,
|
||||
panels: {
|
||||
boards: createMockPanel(),
|
||||
gallery: createMockPanel(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('AppNavigationApi', () => {
|
||||
let navigationApi: AppNavigationApi;
|
||||
let mockSetAppTab: ReturnType<typeof vi.fn>;
|
||||
let mockGetAppTab: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
navigationApi = new AppNavigationApi();
|
||||
mockSetAppTab = vi.fn();
|
||||
mockGetAppTab = vi.fn();
|
||||
});
|
||||
|
||||
describe('Basic Connection', () => {
|
||||
it('should connect to app', () => {
|
||||
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
|
||||
|
||||
expect(navigationApi.setAppTab).toBe(mockSetAppTab);
|
||||
expect(navigationApi.getAppTab).toBe(mockGetAppTab);
|
||||
});
|
||||
|
||||
it('should disconnect from app', () => {
|
||||
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
|
||||
navigationApi.disconnectFromApp();
|
||||
|
||||
expect(navigationApi.setAppTab).toBeNull();
|
||||
expect(navigationApi.getAppTab).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab Registration', () => {
|
||||
it('should register and unregister tabs', () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
const unregister = navigationApi.registerAppTab('generate', mockLayout);
|
||||
|
||||
expect(typeof unregister).toBe('function');
|
||||
|
||||
// Check that tab is registered using type assertion to access private property
|
||||
expect(navigationApi.appTabApi.generate).toBe(mockLayout);
|
||||
|
||||
// Unregister
|
||||
unregister();
|
||||
expect(navigationApi.appTabApi.generate).toBeNull();
|
||||
});
|
||||
|
||||
it('should notify waiters when tab is registered', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
|
||||
// Start waiting for registration
|
||||
const waitPromise = navigationApi.waitForTabRegistration('generate');
|
||||
|
||||
// Register the tab
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
|
||||
// Wait should resolve
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Panel Focus', () => {
|
||||
beforeEach(() => {
|
||||
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
|
||||
});
|
||||
|
||||
it('should focus panel in already registered tab', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSetAppTab).not.toHaveBeenCalled();
|
||||
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should switch tab before focusing panel', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
mockGetAppTab.mockReturnValue('canvas'); // Currently on different tab
|
||||
|
||||
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSetAppTab).toHaveBeenCalledWith('generate');
|
||||
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should wait for tab registration before focusing', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
// Start focus operation before tab is registered
|
||||
const focusPromise = navigationApi.focusPanelInTab('generate', 'left', 'settings');
|
||||
|
||||
// Register tab after a short delay
|
||||
setTimeout(() => {
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
}, 100);
|
||||
|
||||
const result = await focusPromise;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should focus different panel types', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
// Test gridview panel
|
||||
const result1 = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
|
||||
expect(result1).toBe(true);
|
||||
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
|
||||
|
||||
// Test dockview panel
|
||||
const result2 = await navigationApi.focusPanelInTab('generate', 'main', 'launchpad');
|
||||
expect(result2).toBe(true);
|
||||
expect(mockLayout.panels.main.panels.launchpad.api.setActive).toHaveBeenCalledOnce();
|
||||
|
||||
// Test right panel
|
||||
const result3 = await navigationApi.focusPanelInTab('generate', 'right', 'boards');
|
||||
expect(result3).toBe(true);
|
||||
expect(mockLayout.panels.right.panels.boards.api.setActive).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should return false on registration timeout', async () => {
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
// Set a short timeout for testing
|
||||
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
|
||||
// Make setActive throw an error
|
||||
vi.mocked(mockLayout.panels.left.panels.settings.api.setActive).mockImplementation(() => {
|
||||
throw new Error('Mock error');
|
||||
});
|
||||
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should work without app connection', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
|
||||
// Don't connect to app
|
||||
const result = await navigationApi.focusPanelInTab('generate', 'left', 'settings');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockLayout.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Registration Waiting', () => {
|
||||
it('should resolve immediately for already registered tabs', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
|
||||
const waitPromise = navigationApi.waitForTabRegistration('generate');
|
||||
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple waiters', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
|
||||
const waitPromise1 = navigationApi.waitForTabRegistration('generate');
|
||||
const waitPromise2 = navigationApi.waitForTabRegistration('generate');
|
||||
|
||||
setTimeout(() => {
|
||||
navigationApi.registerAppTab('generate', mockLayout);
|
||||
}, 50);
|
||||
|
||||
await expect(Promise.all([waitPromise1, waitPromise2])).resolves.toEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
it('should timeout if tab is not registered', async () => {
|
||||
const waitPromise = navigationApi.waitForTabRegistration('generate', 100);
|
||||
|
||||
await expect(waitPromise).rejects.toThrow('Tab generate registration timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should handle complete workflow', async () => {
|
||||
const mockLayout = createMockGenerateLayout();
|
||||
|
||||
// Connect to app
|
||||
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
|
||||
mockGetAppTab.mockReturnValue('canvas');
|
||||
|
||||
// Register tab
|
||||
const unregister = navigationApi.registerAppTab('generate', mockLayout);
|
||||
|
||||
// Focus panel (should switch tab and focus)
|
||||
const result = await navigationApi.focusPanelInTab('generate', 'right', 'gallery');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSetAppTab).toHaveBeenCalledWith('generate');
|
||||
expect(mockLayout.panels.right.panels.gallery.api.setActive).toHaveBeenCalledOnce();
|
||||
|
||||
// Cleanup
|
||||
unregister();
|
||||
navigationApi.disconnectFromApp();
|
||||
|
||||
expect(navigationApi.setAppTab).toBeNull();
|
||||
expect(navigationApi.getAppTab).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple tabs sequentially', async () => {
|
||||
const mockLayout1 = createMockGenerateLayout();
|
||||
const mockLayout2 = createMockGenerateLayout();
|
||||
|
||||
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
|
||||
mockGetAppTab.mockReturnValue('generate');
|
||||
|
||||
// Register first tab
|
||||
const unregister1 = navigationApi.registerAppTab('generate', mockLayout1);
|
||||
|
||||
// Focus panel in first tab
|
||||
await navigationApi.focusPanelInTab('generate', 'left', 'settings');
|
||||
expect(mockLayout1.panels.left.panels.settings.api.setActive).toHaveBeenCalledOnce();
|
||||
|
||||
// Replace with second tab
|
||||
unregister1();
|
||||
navigationApi.registerAppTab('generate', mockLayout2);
|
||||
|
||||
// Focus panel in second tab
|
||||
await navigationApi.focusPanelInTab('generate', 'right', 'boards');
|
||||
expect(mockLayout2.panels.right.panels.boards.api.setActive).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
|
||||
import type { TabName } from 'features/ui/store/uiTypes';
|
||||
|
||||
const log = logger('system');
|
||||
|
||||
export type GenerateTabLayout = {
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
left: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
settings: Readonly<IGridviewPanel>;
|
||||
};
|
||||
};
|
||||
main: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
dockviewApi: Readonly<DockviewApi>;
|
||||
panels: {
|
||||
launchpad: Readonly<IDockviewPanel>;
|
||||
viewer: Readonly<IDockviewPanel>;
|
||||
};
|
||||
};
|
||||
right: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
boards: Readonly<IGridviewPanel>;
|
||||
gallery: Readonly<IGridviewPanel>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CanvasTabLayout = {
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
left: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
settings: Readonly<IGridviewPanel>;
|
||||
};
|
||||
};
|
||||
main: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
dockviewApi: Readonly<DockviewApi>;
|
||||
panels: {
|
||||
launchpad: Readonly<IDockviewPanel>;
|
||||
workspace: Readonly<IDockviewPanel>;
|
||||
viewer: Readonly<IDockviewPanel>;
|
||||
};
|
||||
};
|
||||
right: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
boards: Readonly<IGridviewPanel>;
|
||||
gallery: Readonly<IGridviewPanel>;
|
||||
layers: Readonly<IGridviewPanel>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type UpscalingTabLayout = {
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
left: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
settings: Readonly<IGridviewPanel>;
|
||||
};
|
||||
};
|
||||
main: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
dockviewApi: Readonly<DockviewApi>;
|
||||
panels: {
|
||||
launchpad: Readonly<IDockviewPanel>;
|
||||
viewer: Readonly<IDockviewPanel>;
|
||||
};
|
||||
};
|
||||
right: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
boards: Readonly<IGridviewPanel>;
|
||||
gallery: Readonly<IGridviewPanel>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkflowsTabLayout = {
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
left: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
settings: Readonly<IGridviewPanel>;
|
||||
};
|
||||
};
|
||||
main: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
dockviewApi: Readonly<DockviewApi>;
|
||||
panels: {
|
||||
launchpad: Readonly<IDockviewPanel>;
|
||||
workspace: Readonly<IDockviewPanel>;
|
||||
viewer: Readonly<IDockviewPanel>;
|
||||
};
|
||||
};
|
||||
right: {
|
||||
panelApi: Readonly<IGridviewPanel>;
|
||||
gridviewApi: Readonly<GridviewApi>;
|
||||
panels: {
|
||||
boards: Readonly<IGridviewPanel>;
|
||||
gallery: Readonly<IGridviewPanel>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppTabApi = {
|
||||
generate: GenerateTabLayout | null;
|
||||
canvas: CanvasTabLayout | null;
|
||||
upscaling: UpscalingTabLayout | null;
|
||||
workflows: WorkflowsTabLayout | null;
|
||||
};
|
||||
|
||||
export class AppNavigationApi {
|
||||
appTabApi: AppTabApi = {
|
||||
generate: null,
|
||||
canvas: null,
|
||||
upscaling: null,
|
||||
workflows: null,
|
||||
};
|
||||
|
||||
setAppTab: ((tab: TabName) => void) | null = null;
|
||||
getAppTab: (() => TabName) | null = null;
|
||||
|
||||
private registrationWaiters: Map<keyof AppTabApi, Array<() => void>> = new Map();
|
||||
|
||||
connectToApp(arg: { setAppTab: (tab: TabName) => void; getAppTab: () => TabName }): void {
|
||||
const { setAppTab, getAppTab } = arg;
|
||||
this.setAppTab = setAppTab;
|
||||
this.getAppTab = getAppTab;
|
||||
}
|
||||
|
||||
disconnectFromApp(): void {
|
||||
this.setAppTab = null;
|
||||
this.getAppTab = null;
|
||||
}
|
||||
|
||||
registerAppTab<T extends keyof AppTabApi>(tab: T, layout: AppTabApi[T]): () => void {
|
||||
this.appTabApi[tab] = layout;
|
||||
|
||||
// Notify any waiting consumers
|
||||
const waiters = this.registrationWaiters.get(tab);
|
||||
|
||||
if (waiters) {
|
||||
waiters.forEach((resolve) => resolve());
|
||||
this.registrationWaiters.delete(tab);
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.appTabApi[tab] = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a specific panel in a specific tab
|
||||
* Automatically switches to the target tab if specified
|
||||
*/
|
||||
async focusPanelInTab<
|
||||
T extends keyof AppTabApi,
|
||||
R extends keyof NonNullable<AppTabApi[T]>['panels'],
|
||||
P extends keyof (NonNullable<AppTabApi[T]>['panels'][R] extends { panels: infer Panels } ? Panels : never),
|
||||
>(tabName: T, rootPanelId: R, panelId: P): Promise<boolean> {
|
||||
try {
|
||||
if (this.setAppTab && this.getAppTab && this.getAppTab() !== tabName) {
|
||||
this.setAppTab(tabName);
|
||||
}
|
||||
|
||||
await this.waitForTabRegistration(tabName);
|
||||
|
||||
const tabLayout = this.appTabApi[tabName];
|
||||
if (!tabLayout) {
|
||||
log.error(`Tab ${tabName} failed to register`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const panel = tabLayout.panels[rootPanelId].panels[panelId] as IGridviewPanel | IDockviewPanel;
|
||||
|
||||
panel.api.setActive();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error(`Failed to focus panel ${String(panelId)} in tab ${tabName}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
waitForTabRegistration<T extends keyof AppTabApi>(tabName: T, timeout = 1000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.appTabApi[tabName]) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let waiters = this.registrationWaiters.get(tabName);
|
||||
if (!waiters) {
|
||||
waiters = [];
|
||||
this.registrationWaiters.set(tabName, waiters);
|
||||
}
|
||||
waiters.push(resolve);
|
||||
const intervalId = setInterval(() => {
|
||||
if (this.appTabApi[tabName]) {
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
clearInterval(intervalId);
|
||||
if (this.appTabApi[tabName]) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Tab ${tabName} registration timed out`));
|
||||
}
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const navigationApi = new AppNavigationApi();
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user