feat(ui): panel state persistence (WIP)

This commit is contained in:
psychedelicious
2025-07-08 08:57:12 +10:00
parent 18212c7d8a
commit 69a08ee7f2
8 changed files with 203 additions and 112 deletions

View File

@@ -112,7 +112,8 @@ const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
},
});
// Register panels with navigation API
launchpad.api.setActive();
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
@@ -125,23 +126,7 @@ const MainPanel = memo(() => {
const onReady = useCallback<IDockviewReactProps['onReady']>(
({ api }) => {
const panels = initializeCenterPanelLayout(tab, api);
panels.launchpad.api.setActive();
const disposables = [
api.onWillShowOverlay((e) => {
if (e.kind === 'header_space' || e.kind === 'tab') {
return;
}
e.preventDefault();
}),
];
return () => {
disposables.forEach((disposable) => {
disposable.dispose();
});
};
initializeCenterPanelLayout(tab, api);
},
[tab]
);

View File

@@ -91,7 +91,8 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
},
});
// Register panels with navigation API
launchpad.api.setActive();
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
@@ -103,23 +104,7 @@ const MainPanel = memo(() => {
const onReady = useCallback<IDockviewReactProps['onReady']>(
({ api }) => {
const { launchpad } = initializeMainPanelLayout(tab, api);
launchpad.api.setActive();
const disposables = [
api.onWillShowOverlay((e) => {
if (e.kind === 'header_space' || e.kind === 'tab') {
return;
}
e.preventDefault();
}),
];
return () => {
disposables.forEach((disposable) => {
disposable.dispose();
});
};
initializeMainPanelLayout(tab, api);
},
[tab]
);

View File

@@ -1,7 +1,8 @@
import { logger } from 'app/logging/logger';
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 { DockviewPanel, GridviewPanel, type IDockviewPanel, type IGridviewPanel } from 'dockview';
import { debounce } from 'es-toolkit';
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
import { atom } from 'nanostores';
import {
@@ -26,6 +27,18 @@ type Waiter = {
timeoutId: ReturnType<typeof setTimeout> | null;
};
export type NavigationAppApi = {
activeTab: {
get: () => TabName;
set: (tab: TabName) => void;
};
panelStorage: {
get: (id: string) => StoredDockviewPanelState | StoredGridviewPanelState | undefined;
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => void;
delete: (id: string) => void;
};
};
export class NavigationApi {
/**
* Map of registered panels, keyed by tab and panel ID in this format:
@@ -55,33 +68,22 @@ export class NavigationApi {
*/
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;
_app: NavigationAppApi | 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
* @param api - The application API that provides methods to set and get the current app tab and manage panel
* state storage.
*/
connectToApp = (arg: { setAppTab: (tab: TabName) => void; getAppTab: () => TabName }): void => {
const { setAppTab, getAppTab } = arg;
this._setAppTab = setAppTab;
this._getAppTab = getAppTab;
connectToApp = (api: NavigationAppApi): void => {
this._app = api;
};
/**
* Disconnect from the application, clearing the tab management functions.
*/
disconnectFromApp = (): void => {
this._setAppTab = null;
this._getAppTab = null;
this._app = null;
};
/**
@@ -97,13 +99,13 @@ export class NavigationApi {
clearTimeout(this.switchingTabsTimeout);
this.switchingTabsTimeout = null;
}
if (tab === this._getAppTab?.()) {
if (tab === this._app?.activeTab.get?.()) {
return true;
}
this.$isSwitchingTabs.set(true);
log.debug(`Switching to tab: ${tab}`);
if (this._setAppTab) {
this._setAppTab(tab);
if (this._app) {
this._app.activeTab.set(tab);
return true;
} else {
log.error('No setAppTab function available to switch tabs');
@@ -123,6 +125,100 @@ export class NavigationApi {
}, SWITCH_TABS_FAKE_DELAY_MS);
};
_initGridviewPanelStorage = (key: string, panel: IGridviewPanel) => {
if (!this._app) {
log.error('App not connected');
return;
}
const storedState = this._app.panelStorage.get(key);
if (!storedState) {
log.debug('No stored state for panel, setting initial state');
const { height, width } = panel.api;
this._app.panelStorage.set(key, {
id: key,
type: 'gridview-panel',
dimensions: { height, width },
});
} else {
if (storedState.type !== 'gridview-panel') {
log.error(`Panel ${key} type mismatch: expected gridview-panel, got ${storedState.type}`);
this._app.panelStorage.delete(key);
return;
}
log.debug({ storedState }, 'Found stored state for panel, restoring');
panel.api.setSize(storedState.dimensions);
}
const { dispose } = panel.api.onDidDimensionsChange(
debounce(({ width, height }) => {
log.debug({ key, width, height }, 'Panel dimensions changed');
if (!this._app) {
log.error('App not connected');
return;
}
this._app.panelStorage.set(key, {
id: key,
type: 'gridview-panel',
dimensions: { width, height },
});
}, 1000)
);
return dispose;
};
_initDockviewPanelStorage = (key: string, panel: IDockviewPanel) => {
if (!this._app) {
log.error('App not connected');
return;
}
const storedState = this._app.panelStorage.get(key);
if (!storedState) {
const { isActive } = panel.api;
this._app.panelStorage.set(key, {
id: key,
type: 'dockview-panel',
isActive,
});
} else {
if (storedState.type !== 'dockview-panel') {
log.error(`Panel ${key} type mismatch: expected dockview-panel, got ${storedState.type}`);
this._app.panelStorage.delete(key);
return;
}
if (storedState.isActive) {
panel.api.setActive();
}
}
const { dispose } = panel.api.onDidActiveChange(
debounce(({ isActive }) => {
if (!this._app) {
log.error('App not connected');
return;
}
this._app.panelStorage.set(key, {
id: key,
type: 'dockview-panel',
isActive,
});
}, 1000)
);
return dispose;
};
_initPanelStorage = (key: string, panel: PanelType) => {
if (panel instanceof GridviewPanel) {
return this._initGridviewPanelStorage(key, panel);
} else if (panel instanceof DockviewPanel) {
return this._initDockviewPanelStorage(key, panel);
} else {
log.error(`Unsupported panel type: ${panel.constructor.name}`);
return;
}
};
/**
* Registers a panel with the navigation API.
*
@@ -136,6 +232,8 @@ export class NavigationApi {
this.panels.set(key, panel);
const cleanupPanelStorage = this._initPanelStorage(key, panel);
// Resolve any pending waiters for this panel, notifying them that the panel is now registered.
const waiter = this.waiters.get(key);
if (waiter) {
@@ -149,6 +247,7 @@ export class NavigationApi {
log.debug(`Registered panel ${key}`);
return () => {
cleanupPanelStorage?.();
this.panels.delete(key);
log.debug(`Unregistered panel ${key}`);
};
@@ -280,7 +379,7 @@ export class NavigationApi {
* }
*/
focusPanelInActiveTab = (panelId: string, timeout = 2000): Promise<boolean> => {
const activeTab = this._getAppTab ? this._getAppTab() : null;
const activeTab = this._app?.activeTab.get() ?? null;
if (!activeTab) {
log.error('No active tab found');
return Promise.resolve(false);
@@ -326,7 +425,7 @@ export class NavigationApi {
* @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;
const activeTab = this._app?.activeTab.get() ?? null;
if (!activeTab) {
log.warn('No active tab found to toggle left panel');
return false;
@@ -359,7 +458,7 @@ export class NavigationApi {
* @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;
const activeTab = this._app?.activeTab.get() ?? null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;
@@ -393,7 +492,7 @@ export class NavigationApi {
* @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;
const activeTab = this._app?.activeTab.get() ?? null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;
@@ -433,7 +532,7 @@ export class NavigationApi {
* @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;
const activeTab = this._app?.activeTab.get() ?? null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;

View File

@@ -91,7 +91,8 @@ const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
},
});
// Register panels with navigation API
launchpad.api.setActive();
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
@@ -102,23 +103,7 @@ const MainPanel = memo(() => {
const { tab } = useAutoLayoutContext();
const onReady = useCallback<IDockviewReactProps['onReady']>(
({ api }) => {
const panels = initializeCenterPanelLayout(tab, api);
panels.launchpad.api.setActive();
const disposables = [
api.onWillShowOverlay((e) => {
if (e.kind === 'header_space' || e.kind === 'tab') {
return;
}
e.preventDefault();
}),
];
return () => {
disposables.forEach((disposable) => {
disposable.dispose();
});
};
initializeCenterPanelLayout(tab, api);
},
[tab]
);

View File

@@ -1,29 +1,46 @@
import { useAppStore } from 'app/store/storeHooks';
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 } from 'react';
import { panelStateChanged, setActiveTab } from 'features/ui/store/uiSlice';
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
import { useEffect, useMemo } from 'react';
import { navigationApi } from './navigation-api';
/**
* Hook that initializes the global navigation API with callbacks to access and modify the active tab.
* Hook that initializes the global navigation API with callbacks to access and modify the active tab and handle
* stored panel states.
*/
export const useNavigationApi = () => {
useAssertSingleton('useNavigationApi');
const store = useAppStore();
const getAppTab = useCallback(() => {
return selectActiveTab(store.getState());
}, [store]);
const setAppTab = useCallback(
(tab: TabName) => {
store.dispatch(setActiveTab(tab));
},
const appApi = useMemo(
() => ({
activeTab: {
get: (): TabName => {
return selectActiveTab(store.getState());
},
set: (tab: TabName) => {
store.dispatch(setActiveTab(tab));
},
},
panelStorage: {
get: (id: string) => {
return store.getState().ui.panels[id];
},
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => {
store.dispatch(panelStateChanged({ id, state }));
},
delete: (id: string) => {
store.dispatch(panelStateChanged({ id, state: undefined }));
},
},
}),
[store]
);
useEffect(() => {
navigationApi.connectToApp({ getAppTab, setAppTab });
}, [getAppTab, setAppTab, store]);
navigationApi.connectToApp(appApi);
}, [appApi, store]);
};

View File

@@ -109,7 +109,8 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
},
});
// Register panels with navigation API
launchpad.api.setActive();
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
@@ -122,23 +123,7 @@ const MainPanel = memo(() => {
const onReady = useCallback<IDockviewReactProps['onReady']>(
({ api }) => {
const panels = initializeMainPanelLayout(tab, api);
panels.launchpad.api.setActive();
const disposables = [
api.onWillShowOverlay((e) => {
if (e.kind === 'header_space' || e.kind === 'tab') {
return;
}
e.preventDefault();
}),
];
return () => {
disposables.forEach((disposable) => {
disposable.dispose();
});
};
initializeMainPanelLayout(tab, api);
},
[tab]
);

View File

@@ -51,6 +51,20 @@ export const uiSlice = createSlice({
const { id, size } = action.payload;
state.textAreaSizes[id] = size;
},
panelStateChanged: (
state,
action: PayloadAction<{
id: keyof UIState['panels'];
state: UIState['panels'][keyof UIState['panels']] | undefined;
}>
) => {
const { id, state: panelState } = action.payload;
if (panelState) {
state.panels[id] = panelState;
} else {
delete state.panels[id];
}
},
shouldShowNotificationChanged: (state, action: PayloadAction<UIState['shouldShowNotificationV2']>) => {
state.shouldShowNotificationV2 = action.payload;
},
@@ -66,6 +80,7 @@ export const {
expanderStateChanged,
shouldShowNotificationChanged,
textAreaSizesStateChanged,
panelStateChanged,
} = uiSlice.actions;
export const selectUiSlice = (state: RootState) => state.ui;

View File

@@ -10,6 +10,25 @@ const zPartialDimensions = z.object({
height: z.number().optional(),
});
const zDimensions = z.object({
width: z.number(),
height: z.number(),
});
const zDockviewPanelState = z.object({
id: z.string(),
type: z.literal('dockview-panel'),
isActive: z.boolean(),
});
export type StoredDockviewPanelState = z.infer<typeof zDockviewPanelState>;
const zGridviewPanelState = z.object({
id: z.string(),
type: z.literal('gridview-panel'),
dimensions: zDimensions,
});
export type StoredGridviewPanelState = z.infer<typeof zGridviewPanelState>;
const zUIState = z.object({
_version: z.literal(3).default(3),
activeTab: zTabName.default('canvas'),
@@ -19,6 +38,7 @@ const zUIState = z.object({
accordions: z.record(z.string(), z.boolean()).default(() => ({})),
expanders: z.record(z.string(), z.boolean()).default(() => ({})),
textAreaSizes: z.record(z.string(), zPartialDimensions).default({}),
panels: z.record(z.string(), z.discriminatedUnion('type', [zDockviewPanelState, zGridviewPanelState])).default({}),
shouldShowNotificationV2: z.boolean().default(true),
});
const INITIAL_STATE = zUIState.parse({});