give a panel its own settings, and carry them when it moves
A panel can now hold an opaque config blob that the framework stores, moves and deletes
but never reads. It lives on the layout node for the same reason zoom does: the layout is
already persisted per panel and server-side, so a panel's configuration outlives the tab
and is deleted exactly when the panel is.
swapPanels and movePanel now exchange { appType, config } as one unit. They used to carry
only the app type, which would have silently reset a configured panel to defaults on a drag.
Apps read it through usePanelConfig(panelId); PanelSlot already passes panelId to every
registry app, so nothing else in the framework had to change.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import type { PanelConfig } from './types';
|
||||
|
||||
export type DefaultFileSort = {
|
||||
field: 'name' | 'size' | 'type' | 'date';
|
||||
@@ -14,6 +15,10 @@ type WorkspaceContextValue = {
|
||||
initialFilePath?: string;
|
||||
defaultFileSort?: DefaultFileSort;
|
||||
promptPrefix?: string;
|
||||
// Per-panel app settings, by panel id. A map rather than a getter so a config change re-renders the
|
||||
// panel that owns it — apps read this through `usePanelConfig`, not directly.
|
||||
panelConfigs: Record<string, PanelConfig>;
|
||||
setPanelConfig: (panelId: string, config: PanelConfig | undefined) => void;
|
||||
swapSourceId: string | null;
|
||||
setSwapSourceId: (id: string | null) => void;
|
||||
onSwap: (sourceId: string, targetId: string) => void;
|
||||
@@ -33,6 +38,8 @@ const noop = () => {};
|
||||
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||
dashboardId: null,
|
||||
cwd: '~',
|
||||
panelConfigs: {},
|
||||
setPanelConfig: noop,
|
||||
swapSourceId: null,
|
||||
setSwapSourceId: noop,
|
||||
onSwap: noop,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { LayoutNode, AppRegistryMap, PanelComponents } from './types';
|
||||
import { updateSizes } from './layout-utils';
|
||||
import { updateSizes, collectPanelConfigs } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||
@@ -33,7 +33,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={{ dashboardId: dashboardId ?? null, cwd: cwd ?? '~', promptPrefix, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, onSetZoom: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
|
||||
<WorkspaceProvider value={{ dashboardId: dashboardId ?? null, cwd: cwd ?? '~', promptPrefix, panelConfigs: collectPanelConfigs(layout), setPanelConfig: noop, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, onSetZoom: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { flushSync } from 'react-dom';
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useSessionState } from 'hooks/useSessionState';
|
||||
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents } from './types';
|
||||
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
|
||||
import type { DefaultFileSort } from './WorkspaceContext';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||
@@ -94,6 +94,17 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
// Derived, not stored: the layout is the single copy, so a panel's config cannot drift from the panel.
|
||||
const panelConfigs = useMemo(() => collectPanelConfigs(layout), [layout]);
|
||||
|
||||
const handleSetPanelConfig = useCallback(
|
||||
(panelId: string, config: PanelConfig | undefined) => {
|
||||
const next = setPanelConfig(layout, panelId, config);
|
||||
if (next !== layout) onLayoutChange(next);
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleSwap = useCallback(
|
||||
(sourceId: string, targetId: string) => {
|
||||
onLayoutChange(swapPanels(layout, sourceId, targetId));
|
||||
@@ -166,6 +177,8 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
|
||||
initialFilePath,
|
||||
defaultFileSort,
|
||||
promptPrefix,
|
||||
panelConfigs,
|
||||
setPanelConfig: locked ? noop : handleSetPanelConfig,
|
||||
swapSourceId,
|
||||
setSwapSourceId,
|
||||
onSwap: handleSwap,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type {
|
||||
PanelComponentEntry,
|
||||
EphemeralPanels,
|
||||
HomeRoot,
|
||||
PanelConfig,
|
||||
} from './types';
|
||||
export type { DropPosition } from './layout-utils';
|
||||
export {
|
||||
@@ -23,8 +24,11 @@ export {
|
||||
pruneEmptyPanels,
|
||||
countPanels,
|
||||
hasAnyApp,
|
||||
setPanelConfig,
|
||||
collectPanelConfigs,
|
||||
} from './layout-utils';
|
||||
export type { DefaultFileSort } from './WorkspaceContext';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
export { usePanelConfig } from './usePanelConfig';
|
||||
export { WorkspaceView } from './WorkspaceView';
|
||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LayoutNode, LayoutPanel, LayoutGroup } from './types';
|
||||
import type { LayoutNode, LayoutPanel, LayoutGroup, PanelConfig } from './types';
|
||||
|
||||
let counter = 0;
|
||||
const uid = () => `p-${Date.now()}-${++counter}`;
|
||||
@@ -91,17 +91,6 @@ export function removePanel(root: LayoutNode, panelId: string): LayoutNode {
|
||||
}
|
||||
}
|
||||
|
||||
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
|
||||
if (root.type === 'panel') {
|
||||
return root.id === panelId ? { ...root, appType } : root;
|
||||
}
|
||||
const newChildren = root.children.map((child) => ({
|
||||
...child,
|
||||
node: setApp(child.node, panelId, appType),
|
||||
}));
|
||||
return { ...root, children: newChildren };
|
||||
}
|
||||
|
||||
export const ZOOM_MIN = 0.7;
|
||||
export const ZOOM_MAX = 1.6;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
@@ -171,25 +160,32 @@ export function movePanel(root: LayoutNode, sourceId: string, targetId: string,
|
||||
if (sourceId === targetId) return root;
|
||||
if (position === 'center') return swapPanels(root, sourceId, targetId);
|
||||
|
||||
const sourceApp = findPanelApp(root, sourceId);
|
||||
if (sourceApp === undefined) return root;
|
||||
const contents = findPanelContents(root, sourceId);
|
||||
if (!contents) return root;
|
||||
|
||||
let result = removePanel(root, sourceId);
|
||||
const direction = position === 'top' || position === 'bottom' ? 'vertical' : 'horizontal';
|
||||
const before = position === 'top' || position === 'left';
|
||||
return insertPanel(result, targetId, direction, before, sourceApp);
|
||||
return insertPanel(result, targetId, direction, before, contents);
|
||||
}
|
||||
|
||||
const newPanelFrom = (contents: PanelContents): LayoutPanel => ({
|
||||
type: 'panel',
|
||||
id: uid(),
|
||||
appType: contents.appType,
|
||||
...(contents.config && Object.keys(contents.config).length > 0 ? { config: contents.config } : {}),
|
||||
});
|
||||
|
||||
function insertPanel(
|
||||
node: LayoutNode,
|
||||
targetId: string,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
before: boolean,
|
||||
appType: string | null,
|
||||
contents: PanelContents,
|
||||
): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
if (node.id !== targetId) return node;
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType };
|
||||
const newPanel = newPanelFrom(contents);
|
||||
const children = before
|
||||
? [
|
||||
{ node: newPanel, size: 50 },
|
||||
@@ -204,7 +200,7 @@ function insertPanel(
|
||||
|
||||
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === targetId);
|
||||
if (childIdx !== -1 && node.direction === direction) {
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType };
|
||||
const newPanel = newPanelFrom(contents);
|
||||
const insertIdx = before ? childIdx : childIdx + 1;
|
||||
const newChildren = [
|
||||
...node.children.slice(0, insertIdx),
|
||||
@@ -217,7 +213,7 @@ function insertPanel(
|
||||
|
||||
const newChildren = node.children.map((child) => ({
|
||||
...child,
|
||||
node: insertPanel(child.node, targetId, direction, before, appType),
|
||||
node: insertPanel(child.node, targetId, direction, before, contents),
|
||||
}));
|
||||
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
|
||||
return { ...node, children: newChildren };
|
||||
@@ -225,17 +221,70 @@ function insertPanel(
|
||||
return node;
|
||||
}
|
||||
|
||||
// Swap and move exchange the panel's *contents*, not its node: ids are positions in the tree and stay
|
||||
// where they are. What moves is `{ appType, config }` together — an app and the settings it was given
|
||||
// are one thing, and carrying only the app type would silently reset a configured panel to defaults on
|
||||
// a drag. (That is what used to happen: the two mutators below were written when `appType` was the
|
||||
// whole of a panel's identity.)
|
||||
type PanelContents = { appType: string | null; config?: PanelConfig };
|
||||
|
||||
export function swapPanels(root: LayoutNode, idA: string, idB: string): LayoutNode {
|
||||
const appA = findPanelApp(root, idA);
|
||||
const appB = findPanelApp(root, idB);
|
||||
if (appA === undefined || appB === undefined) return root;
|
||||
return setApp(setApp(root, idA, appB), idB, appA);
|
||||
const a = findPanelContents(root, idA);
|
||||
const b = findPanelContents(root, idB);
|
||||
if (!a || !b) return root;
|
||||
return setContents(setContents(root, idA, b), idB, a);
|
||||
}
|
||||
|
||||
function findPanelApp(node: LayoutNode, panelId: string): string | null | undefined {
|
||||
if (node.type === 'panel') return node.id === panelId ? node.appType : undefined;
|
||||
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
|
||||
// Clearing the app clears its config with it — the next app to occupy the panel would not understand
|
||||
// the old one's settings, and leaving them would resurrect them if the same app came back later.
|
||||
return setContents(root, panelId, appType === null ? { appType: null } : { appType, config: findPanelContents(root, panelId)?.config });
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an app's opaque settings blob. Storing `undefined` or an empty object drops the key entirely,
|
||||
* so an unconfigured panel adds nothing to the persisted layout.
|
||||
*/
|
||||
export function setPanelConfig(root: LayoutNode, panelId: string, config: PanelConfig | undefined): LayoutNode {
|
||||
const current = findPanelContents(root, panelId);
|
||||
if (!current) return root;
|
||||
return setContents(root, panelId, { appType: current.appType, config });
|
||||
}
|
||||
|
||||
/** Every configured panel in the tree, by id — the reactive view apps read through `usePanelConfig`. */
|
||||
export function collectPanelConfigs(node: LayoutNode): Record<string, PanelConfig> {
|
||||
const out: Record<string, PanelConfig> = {};
|
||||
walk(node);
|
||||
return out;
|
||||
|
||||
function walk(n: LayoutNode): void {
|
||||
if (n.type === 'panel') {
|
||||
if (n.config) out[n.id] = n.config;
|
||||
return;
|
||||
}
|
||||
for (const child of n.children) walk(child.node);
|
||||
}
|
||||
}
|
||||
|
||||
function setContents(root: LayoutNode, panelId: string, contents: PanelContents): LayoutNode {
|
||||
if (root.type === 'panel') {
|
||||
if (root.id !== panelId) return root;
|
||||
const { config: _drop, ...rest } = root;
|
||||
const next: LayoutPanel = { ...rest, appType: contents.appType };
|
||||
if (contents.config && Object.keys(contents.config).length > 0) next.config = contents.config;
|
||||
return next;
|
||||
}
|
||||
const newChildren = root.children.map((child) => {
|
||||
const node = setContents(child.node, panelId, contents);
|
||||
return node === child.node ? child : { ...child, node };
|
||||
});
|
||||
return newChildren.some((c, i) => c !== root.children[i]) ? { ...root, children: newChildren } : root;
|
||||
}
|
||||
|
||||
function findPanelContents(node: LayoutNode, panelId: string): PanelContents | undefined {
|
||||
if (node.type === 'panel') return node.id === panelId ? { appType: node.appType, config: node.config } : undefined;
|
||||
for (const child of node.children) {
|
||||
const result = findPanelApp(child.node, panelId);
|
||||
const result = findPanelContents(child.node, panelId);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -17,8 +17,21 @@ export type LayoutPanel = {
|
||||
// because the layout is already persisted per panel — a separate key would seed a row per panel on
|
||||
// mount (useDashboardState seeds defaults to the server), and would need its own dispatcher branch.
|
||||
zoom?: number;
|
||||
// Opaque per-panel settings owned by whichever app the panel is running. The framework stores, moves
|
||||
// and deletes it and never reads a field — the shape is the app's business (see `usePanelConfig`).
|
||||
//
|
||||
// Here for the same reason `zoom` is: the layout is the one thing already persisted per panel, and it
|
||||
// is persisted server-side, which is what makes a panel's configuration outlive the tab. It also means
|
||||
// the config is deleted exactly when the panel is, with no separate lifecycle to get wrong.
|
||||
//
|
||||
// It travels with the panel through swap and move — see `swapPanels`/`movePanel`, which carry
|
||||
// `{ appType, config }` as one unit precisely because an app's identity is both.
|
||||
config?: PanelConfig;
|
||||
};
|
||||
|
||||
/** Opaque to the framework. Each app defines and validates its own shape. */
|
||||
export type PanelConfig = Record<string, unknown>;
|
||||
|
||||
export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||
|
||||
export type HomeRoot = 'home' | '~' | 'officer.dev';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
import type { PanelConfig } from './types';
|
||||
|
||||
/**
|
||||
* Read and write one panel's own settings.
|
||||
*
|
||||
* The blob lives on the panel node in the dashboard layout, which means it is persisted server-side,
|
||||
* survives a reload and a restart, travels with the panel through swap and drag, and is deleted exactly
|
||||
* when the panel is. The framework never looks inside it — `T` is a promise the calling app makes to
|
||||
* itself, so validate on read if the shape has changed since a config was written.
|
||||
*
|
||||
* `setConfig` takes a whole object or an updater, like `useState`. Writing `undefined` — or an object
|
||||
* with no keys — removes the config entirely, so an untouched panel adds nothing to the persisted layout.
|
||||
*
|
||||
* Returns a no-op setter in a locked or preview workspace (`WorkspaceLayout`, the dashboard-creation
|
||||
* preview): those render a layout they do not own, and a write would have nowhere to go.
|
||||
*/
|
||||
export function usePanelConfig<T extends PanelConfig = PanelConfig>(
|
||||
panelId: string,
|
||||
): [T | undefined, (next: T | undefined | ((prev: T | undefined) => T | undefined)) => void] {
|
||||
const { panelConfigs, setPanelConfig } = useWorkspace();
|
||||
|
||||
const config = panelConfigs[panelId] as T | undefined;
|
||||
|
||||
const setConfig = useCallback(
|
||||
(next: T | undefined | ((prev: T | undefined) => T | undefined)) => {
|
||||
const value = typeof next === 'function' ? next(config) : next;
|
||||
setPanelConfig(panelId, value);
|
||||
},
|
||||
[panelId, config, setPanelConfig],
|
||||
);
|
||||
|
||||
return useMemo(() => [config, setConfig], [config, setConfig]);
|
||||
}
|
||||
Reference in New Issue
Block a user