diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts b/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts index df5d083b..9c236031 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts @@ -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; + 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({ dashboardId: null, cwd: '~', + panelConfigs: {}, + setPanelConfig: noop, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx index 017f0d01..6c5d2874 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx @@ -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 ( - + 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, diff --git a/src/workspaces/officerdev/src/components/Workspace/index.ts b/src/workspaces/officerdev/src/components/Workspace/index.ts index 1b302167..dc38c756 100644 --- a/src/workspaces/officerdev/src/components/Workspace/index.ts +++ b/src/workspaces/officerdev/src/components/Workspace/index.ts @@ -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'; diff --git a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts b/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts index 514e4700..2f9c4450 100644 --- a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts +++ b/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts @@ -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 { + const out: Record = {}; + 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; diff --git a/src/workspaces/officerdev/src/components/Workspace/types.ts b/src/workspaces/officerdev/src/components/Workspace/types.ts index 27fbc16b..8f00be55 100644 --- a/src/workspaces/officerdev/src/components/Workspace/types.ts +++ b/src/workspaces/officerdev/src/components/Workspace/types.ts @@ -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; + export type LayoutNode = LayoutGroup | LayoutPanel; export type HomeRoot = 'home' | '~' | 'officer.dev'; diff --git a/src/workspaces/officerdev/src/components/Workspace/usePanelConfig.ts b/src/workspaces/officerdev/src/components/Workspace/usePanelConfig.ts new file mode 100644 index 00000000..8381247e --- /dev/null +++ b/src/workspaces/officerdev/src/components/Workspace/usePanelConfig.ts @@ -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( + 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]); +}