feat(ui): clean up navigation API surface and add comments

This commit is contained in:
psychedelicious
2025-07-07 22:57:58 +10:00
parent 7de26f8e69
commit 18212c7d8a
2 changed files with 189 additions and 47 deletions

View File

@@ -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,

View File

@@ -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<void>;
timeoutId: ReturnType<typeof setTimeout> | null;
};
export class NavigationApi {
/**
* Map of registered panels, keyed by tab and panel ID in this format:
* `${tab}:${panelId}`
*/
private panels: Map<string, PanelType> = new Map();
/**
* Map of waiters for panel registration.
*/
private waiters: Map<string, Waiter> = 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<typeof setTimeout> | 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<void> => {
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<void>();
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<boolean> => {
focusPanel = async (tab: TabName, panelId: string, timeout = 2000): Promise<boolean> => {
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<boolean> => {
/**
* 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<boolean> => {
const activeTab = this._getAppTab ? this._getAppTab() : null;
if (!activeTab) {
log.error('No active tab found');
return Promise.resolve(false);
}
return this.focusPanel(activeTab, panelId);
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);
}