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 { createContext, useContext } from 'react';
|
||||||
|
|
||||||
import type { DropPosition } from './layout-utils';
|
import type { DropPosition } from './layout-utils';
|
||||||
|
import type { PanelConfig } from './types';
|
||||||
|
|
||||||
export type DefaultFileSort = {
|
export type DefaultFileSort = {
|
||||||
field: 'name' | 'size' | 'type' | 'date';
|
field: 'name' | 'size' | 'type' | 'date';
|
||||||
@@ -14,6 +15,10 @@ type WorkspaceContextValue = {
|
|||||||
initialFilePath?: string;
|
initialFilePath?: string;
|
||||||
defaultFileSort?: DefaultFileSort;
|
defaultFileSort?: DefaultFileSort;
|
||||||
promptPrefix?: string;
|
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;
|
swapSourceId: string | null;
|
||||||
setSwapSourceId: (id: string | null) => void;
|
setSwapSourceId: (id: string | null) => void;
|
||||||
onSwap: (sourceId: string, targetId: string) => void;
|
onSwap: (sourceId: string, targetId: string) => void;
|
||||||
@@ -33,6 +38,8 @@ const noop = () => {};
|
|||||||
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||||
dashboardId: null,
|
dashboardId: null,
|
||||||
cwd: '~',
|
cwd: '~',
|
||||||
|
panelConfigs: {},
|
||||||
|
setPanelConfig: noop,
|
||||||
swapSourceId: null,
|
swapSourceId: null,
|
||||||
setSwapSourceId: noop,
|
setSwapSourceId: noop,
|
||||||
onSwap: noop,
|
onSwap: noop,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { LayoutNode, AppRegistryMap, PanelComponents } from './types';
|
import type { LayoutNode, AppRegistryMap, PanelComponents } from './types';
|
||||||
import { updateSizes } from './layout-utils';
|
import { updateSizes, collectPanelConfigs } from './layout-utils';
|
||||||
import { WorkspaceProvider } from './WorkspaceContext';
|
import { WorkspaceProvider } from './WorkspaceContext';
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||||
@@ -33,7 +33,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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
|
<WorkspaceRenderer
|
||||||
layout={layout}
|
layout={layout}
|
||||||
registry={registry}
|
registry={registry}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { flushSync } from 'react-dom';
|
|||||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
||||||
import { useIsMobile } from 'hooks/useIsMobile';
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
import { useSessionState } from 'hooks/useSessionState';
|
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 { DefaultFileSort } from './WorkspaceContext';
|
||||||
import type { DropPosition } from './layout-utils';
|
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 { WorkspaceProvider } from './WorkspaceContext';
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||||
@@ -94,6 +94,17 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
|
|||||||
[layout, onLayoutChange],
|
[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(
|
const handleSwap = useCallback(
|
||||||
(sourceId: string, targetId: string) => {
|
(sourceId: string, targetId: string) => {
|
||||||
onLayoutChange(swapPanels(layout, sourceId, targetId));
|
onLayoutChange(swapPanels(layout, sourceId, targetId));
|
||||||
@@ -166,6 +177,8 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
|
|||||||
initialFilePath,
|
initialFilePath,
|
||||||
defaultFileSort,
|
defaultFileSort,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
|
panelConfigs,
|
||||||
|
setPanelConfig: locked ? noop : handleSetPanelConfig,
|
||||||
swapSourceId,
|
swapSourceId,
|
||||||
setSwapSourceId,
|
setSwapSourceId,
|
||||||
onSwap: handleSwap,
|
onSwap: handleSwap,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type {
|
|||||||
PanelComponentEntry,
|
PanelComponentEntry,
|
||||||
EphemeralPanels,
|
EphemeralPanels,
|
||||||
HomeRoot,
|
HomeRoot,
|
||||||
|
PanelConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
export type { DropPosition } from './layout-utils';
|
export type { DropPosition } from './layout-utils';
|
||||||
export {
|
export {
|
||||||
@@ -23,8 +24,11 @@ export {
|
|||||||
pruneEmptyPanels,
|
pruneEmptyPanels,
|
||||||
countPanels,
|
countPanels,
|
||||||
hasAnyApp,
|
hasAnyApp,
|
||||||
|
setPanelConfig,
|
||||||
|
collectPanelConfigs,
|
||||||
} from './layout-utils';
|
} from './layout-utils';
|
||||||
export type { DefaultFileSort } from './WorkspaceContext';
|
export type { DefaultFileSort } from './WorkspaceContext';
|
||||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||||
|
export { usePanelConfig } from './usePanelConfig';
|
||||||
export { WorkspaceView } from './WorkspaceView';
|
export { WorkspaceView } from './WorkspaceView';
|
||||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
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;
|
let counter = 0;
|
||||||
const uid = () => `p-${Date.now()}-${++counter}`;
|
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_MIN = 0.7;
|
||||||
export const ZOOM_MAX = 1.6;
|
export const ZOOM_MAX = 1.6;
|
||||||
export const ZOOM_STEP = 0.1;
|
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 (sourceId === targetId) return root;
|
||||||
if (position === 'center') return swapPanels(root, sourceId, targetId);
|
if (position === 'center') return swapPanels(root, sourceId, targetId);
|
||||||
|
|
||||||
const sourceApp = findPanelApp(root, sourceId);
|
const contents = findPanelContents(root, sourceId);
|
||||||
if (sourceApp === undefined) return root;
|
if (!contents) return root;
|
||||||
|
|
||||||
let result = removePanel(root, sourceId);
|
let result = removePanel(root, sourceId);
|
||||||
const direction = position === 'top' || position === 'bottom' ? 'vertical' : 'horizontal';
|
const direction = position === 'top' || position === 'bottom' ? 'vertical' : 'horizontal';
|
||||||
const before = position === 'top' || position === 'left';
|
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(
|
function insertPanel(
|
||||||
node: LayoutNode,
|
node: LayoutNode,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
direction: 'horizontal' | 'vertical',
|
direction: 'horizontal' | 'vertical',
|
||||||
before: boolean,
|
before: boolean,
|
||||||
appType: string | null,
|
contents: PanelContents,
|
||||||
): LayoutNode {
|
): LayoutNode {
|
||||||
if (node.type === 'panel') {
|
if (node.type === 'panel') {
|
||||||
if (node.id !== targetId) return node;
|
if (node.id !== targetId) return node;
|
||||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType };
|
const newPanel = newPanelFrom(contents);
|
||||||
const children = before
|
const children = before
|
||||||
? [
|
? [
|
||||||
{ node: newPanel, size: 50 },
|
{ node: newPanel, size: 50 },
|
||||||
@@ -204,7 +200,7 @@ function insertPanel(
|
|||||||
|
|
||||||
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === targetId);
|
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === targetId);
|
||||||
if (childIdx !== -1 && node.direction === direction) {
|
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 insertIdx = before ? childIdx : childIdx + 1;
|
||||||
const newChildren = [
|
const newChildren = [
|
||||||
...node.children.slice(0, insertIdx),
|
...node.children.slice(0, insertIdx),
|
||||||
@@ -217,7 +213,7 @@ function insertPanel(
|
|||||||
|
|
||||||
const newChildren = node.children.map((child) => ({
|
const newChildren = node.children.map((child) => ({
|
||||||
...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)) {
|
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
|
||||||
return { ...node, children: newChildren };
|
return { ...node, children: newChildren };
|
||||||
@@ -225,17 +221,70 @@ function insertPanel(
|
|||||||
return node;
|
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 {
|
export function swapPanels(root: LayoutNode, idA: string, idB: string): LayoutNode {
|
||||||
const appA = findPanelApp(root, idA);
|
const a = findPanelContents(root, idA);
|
||||||
const appB = findPanelApp(root, idB);
|
const b = findPanelContents(root, idB);
|
||||||
if (appA === undefined || appB === undefined) return root;
|
if (!a || !b) return root;
|
||||||
return setApp(setApp(root, idA, appB), idB, appA);
|
return setContents(setContents(root, idA, b), idB, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPanelApp(node: LayoutNode, panelId: string): string | null | undefined {
|
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
|
||||||
if (node.type === 'panel') return node.id === panelId ? node.appType : undefined;
|
// 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) {
|
for (const child of node.children) {
|
||||||
const result = findPanelApp(child.node, panelId);
|
const result = findPanelContents(child.node, panelId);
|
||||||
if (result !== undefined) return result;
|
if (result !== undefined) return result;
|
||||||
}
|
}
|
||||||
return undefined;
|
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
|
// 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.
|
// mount (useDashboardState seeds defaults to the server), and would need its own dispatcher branch.
|
||||||
zoom?: number;
|
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 LayoutNode = LayoutGroup | LayoutPanel;
|
||||||
|
|
||||||
export type HomeRoot = 'home' | '~' | 'officer.dev';
|
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