extract the workspace framework into its own package
Phase 1: the Workspace/Panel system is `src/workspaces/workspaces`, and it no longer
knows Officer exists.
The proof is its test suite. 114 tests mount `WorkspaceView` under a BARE React tree —
no react-query provider, no router, no Officer hook — which is only possible because
the host now supplies everything: a registry mapping `appType` to a component, and a
layout with a way to change it.
Officer is untouched. All 24 `WorkspaceView` sites and 8 `WorkspaceLayout` sites call
exactly what they called before; `Workspace/index.ts` became an adapter that adds back
the three things that are genuinely ours — the app registry, fullscreen persistence in
sessionStorage, and the handshake that stands the nav header down for a fullscreen
panel.
── The shape, and why it is this shape ──
`core/` is the tree and every operation on it: pure, no dependencies, no React. `react/`
is one renderer. That split is phase 2's whole cost saving — a second renderer reuses
`splitPanel`, `movePanel`, `normalizeLayout` and the rest untouched, and what will not
port is the resize INTERACTION rather than the layout engine.
It was already half done: `layout-utils.ts` imported only `./types`, and `WorkspaceView`
already received `{ key, value, setValue, isLoaded }` as a prop and always wrote through
an updater. The real coupling was the registry, fullscreen, and four UI imports.
Also deleted ~175 lines of dead code — `DragOverlay`, `LayoutEditor`, `WorkspaceHeader`
— referenced by nothing.
── Two dependency traps ──
**A second React.** Declaring react/react-dom in the package produced a second copy
under its own node_modules. Two React instances break hooks at runtime and are invisible
until something renders. The package now declares almost nothing while it lives here.
**Two versions of react-resizable-panels.** The root asks ^4.0.15 (4.6.5),
`src/workspaces/components` asks ^2.1.7 (2.1.9), and this code targets 2.x — v4 has no
`PanelGroup`, no `order`, no `ref` on Panel. Inheriting the root resolution produced a
screenful of type errors that read like a broken extraction and were a version
mismatch. Pinned; the duplication itself is worth fixing one day.
── Verified ──
tsgo clean. Package suite 114/114. Full suite 950 pass / 7 fail, unchanged. Frontend
builds, and the running app was screenshotted against the pre-extraction baseline: 9 of
16 byte-identical, the rest live data and the rotating wallpaper. Panel chrome, headers,
splitters and the grid all render as before.
Docs written as part of the work: the package README is the contract, and
WORKSPACES-NOTES.md carries the open items — panel-to-panel channels, the theme
classes the package still assumes, and phase 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
# Workspaces extraction — phase 1
|
||||||
|
|
||||||
|
Branch `workspaces-extraction`. The Workspace/Panel framework is now `src/workspaces/workspaces`, a package
|
||||||
|
that knows nothing about Officer.
|
||||||
|
|
||||||
|
## Where it got to
|
||||||
|
|
||||||
|
**The package renders under a bare React tree.** Its own test suite (114 tests) mounts `WorkspaceView` with
|
||||||
|
no provider of any kind — no react-query, no router, no Officer hooks. That is the proof: a host needs to
|
||||||
|
supply a registry and a layout, and nothing else.
|
||||||
|
|
||||||
|
**Two halves.** `core/` is the tree and every operation on it — pure, dependency-free, no React. `react/`
|
||||||
|
is one renderer. Phase 2 is a second renderer over the same core.
|
||||||
|
|
||||||
|
**Officer is unchanged.** All 24 `WorkspaceView` sites and 8 `WorkspaceLayout` sites still call exactly
|
||||||
|
what they called before. `officerdev/.../Workspace/index.ts` is now an adapter that supplies the three
|
||||||
|
things that are Officer's: the app registry, fullscreen persistence in sessionStorage, and the handshake
|
||||||
|
that hides the nav header for a fullscreen panel.
|
||||||
|
|
||||||
|
**Persistence was already injected.** `WorkspaceView` always received `{ key, value, setValue, isLoaded }`
|
||||||
|
as a prop and called `setValue` with an updater. The headline requirement of phase 1 was mostly already
|
||||||
|
true — the coupling was elsewhere.
|
||||||
|
|
||||||
|
**~175 lines of dead code deleted**: `DragOverlay`, `LayoutEditor`, `WorkspaceHeader`, referenced by
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
## What actually had to move
|
||||||
|
|
||||||
|
| Coupling | Resolution |
|
||||||
|
|---|---|
|
||||||
|
| `useAppRegistry` | `registry` is now a required prop |
|
||||||
|
| `useSessionState` (fullscreen) | controlled/uncontrolled pair; Officer passes its sessionStorage state |
|
||||||
|
| `panel-fullscreen` / `useGlobal` | the package reports via callback; Officer acts |
|
||||||
|
| `use-panel-memory` / `useDashboardState` | stayed in Officer — it is host storage, not layout |
|
||||||
|
| `@/components/{Card,ErrorBoundary,ui/*}` | vendored into `react/internal/` |
|
||||||
|
| `hooks/useIsMobile` | vendored (27 lines) |
|
||||||
|
| `helpers/cn`, `@/lib/utils` | vendored (2 lines) |
|
||||||
|
|
||||||
|
## Two traps, both worth knowing before touching dependencies
|
||||||
|
|
||||||
|
**A second React.** Declaring `react`/`react-dom` in the package produced a second copy under
|
||||||
|
`src/workspaces/workspaces/node_modules`. Two React instances break hooks at runtime and are invisible
|
||||||
|
until something renders. While this lives in the monorepo the package declares almost nothing and inherits
|
||||||
|
the root's; publishing standalone means declaring them as peers, aligned with the host.
|
||||||
|
|
||||||
|
**Two versions of `react-resizable-panels`.** The root asks for `^4.0.15` (4.6.5); `src/workspaces/components`
|
||||||
|
asks for `^2.1.7` (2.1.9). This code was written against 2.x, whose API differs — v4 has no `PanelGroup`
|
||||||
|
export, no `order` prop, no `ref` on `Panel`. Inheriting the root's resolution produced dozens of type
|
||||||
|
errors that read like a broken extraction and were a version mismatch. The package pins `^2.1.7`.
|
||||||
|
|
||||||
|
Worth resolving properly at some point: one repository, two majors of the same library.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
**1. Panel-to-panel communication.** `usePanelChannel` is a two-line wrapper over `useGlobal`, so it is
|
||||||
|
react-query underneath. Six channels exist; three are genuine refresh signals (`files:refresh-signal`,
|
||||||
|
`preview:refresh`, `background-job:changed`) and two are SELECTION (`chat:selected-session`,
|
||||||
|
`chat:active-session`), which `docs/navigation-audit.md` says belongs in the URL. Deferred by decision: the
|
||||||
|
mechanics will live in the package, configured from outside, probably as callbacks. The real requirement
|
||||||
|
may be smaller than it looks.
|
||||||
|
|
||||||
|
**2. The package still speaks Officer's theme.** `PanelSlot` uses `duck-teal` classes and `--panel-*` CSS
|
||||||
|
variables. Harmless inside Officer, wrong for a standalone module: a consumer would need those variables
|
||||||
|
defined. Options are to accept a class-name map, ship a minimal stylesheet, or document the variables as
|
||||||
|
part of the contract.
|
||||||
|
|
||||||
|
**3. `cwd`/`root` are in the context.** They are Officer concepts (a workspace points at a directory) that
|
||||||
|
the package currently carries. Not harmful, not general.
|
||||||
|
|
||||||
|
**4. Phase 2.** A non-React renderer over `core`. What will not port is the resize *interaction* — drag
|
||||||
|
handles, pointer maths, constraints — supplied today by `react-resizable-panels`. Either find a vanilla
|
||||||
|
equivalent or write one; the tree logic comes across untouched, which was the point of the split.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
`bun test src/workspaces/workspaces/` — 114 tests, no host.
|
||||||
|
Full suite unchanged at 950 pass / 7 fail. Screenshots: 9 of 16 byte-identical to before the extraction,
|
||||||
|
the rest being live data (chat sessions, system metrics) and the rotating wallpaper.
|
||||||
@@ -135,6 +135,7 @@
|
|||||||
"varuint-bitcoin": "^2.0.0",
|
"varuint-bitcoin": "^2.0.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"widgets": "workspace:*",
|
"widgets": "workspace:*",
|
||||||
|
"workspaces": "workspace:*",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod": "^4.2.1",
|
"zod": "^4.2.1",
|
||||||
},
|
},
|
||||||
@@ -298,6 +299,13 @@
|
|||||||
"src/workspaces/widgets": {
|
"src/workspaces/widgets": {
|
||||||
"name": "widgets",
|
"name": "widgets",
|
||||||
},
|
},
|
||||||
|
"src/workspaces/workspaces": {
|
||||||
|
"name": "workspaces",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
@@ -2360,6 +2368,8 @@
|
|||||||
|
|
||||||
"wif": ["wif@5.0.0", "", { "dependencies": { "bs58check": "^4.0.0" } }, "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA=="],
|
"wif": ["wif@5.0.0", "", { "dependencies": { "bs58check": "^4.0.0" } }, "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA=="],
|
||||||
|
|
||||||
|
"workspaces": ["workspaces@workspace:src/workspaces/workspaces"],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
@@ -2640,6 +2650,8 @@
|
|||||||
|
|
||||||
"wif/bs58check": ["bs58check@4.0.0", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^6.0.0" } }, "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g=="],
|
"wif/bs58check": ["bs58check@4.0.0", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^6.0.0" } }, "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g=="],
|
||||||
|
|
||||||
|
"workspaces/react-resizable-panels": ["react-resizable-panels@2.1.9", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ=="],
|
||||||
|
|
||||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
"varuint-bitcoin": "^2.0.0",
|
"varuint-bitcoin": "^2.0.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"widgets": "workspace:*",
|
"widgets": "workspace:*",
|
||||||
|
"workspaces": "workspace:*",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/home/pastilhas/officer-dev/platform/plugins/example
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/home/pastilhas/officer-dev/platform/plugins/music
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/home/pastilhas/officer-dev/platform/plugins/offscale
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { parseWorkspaceKey } from 'workspaces/core';
|
||||||
|
import { terminalStateKey } from './state-key';
|
||||||
|
|
||||||
|
// Split out of the workspace identity tests when the layout framework became its own package. Parsing a
|
||||||
|
// workspace key is the package's; what a TERMINAL does with the result is Officer's, and a package test
|
||||||
|
// that imports an Officer app is a package that is not extracted.
|
||||||
|
|
||||||
|
describe('terminalStateKey', () => {
|
||||||
|
// The regression this rule exists for: a wrapper that passed the raw key through produced
|
||||||
|
// `ws-host-terminals-ws-layout-my-dash`, which the server parsed back out as a dashboard id and created.
|
||||||
|
test('strips the layout prefix rather than nesting it', () => {
|
||||||
|
const key = terminalStateKey('host-terminals', parseWorkspaceKey('ws-layout-my-dash'));
|
||||||
|
expect(key).toBe('ws-host-terminals-my-dash');
|
||||||
|
expect(key).not.toContain('ws-layout');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('screens and unknown workspaces share the defaults row', () => {
|
||||||
|
expect(terminalStateKey('terminals', parseWorkspaceKey('screens/chat'))).toBe('ws-terminals-default');
|
||||||
|
expect(terminalStateKey('terminals', null)).toBe('ws-terminals-default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carries the panel-state prefixes the dispatcher allow-lists', () => {
|
||||||
|
const dashboard = parseWorkspaceKey('ws-layout-d1');
|
||||||
|
expect(terminalStateKey('tmux', dashboard)).toBe('ws-tmux-d1');
|
||||||
|
expect(terminalStateKey('nvim', dashboard)).toBe('ws-nvim-d1');
|
||||||
|
expect(terminalStateKey('claude-code', dashboard)).toBe('ws-claude-code-d1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import type { DropPosition } from './layout-utils';
|
|
||||||
import { useWorkspace } from './WorkspaceContext';
|
|
||||||
|
|
||||||
type DropTarget = {
|
|
||||||
panelId: string;
|
|
||||||
zone: DropPosition;
|
|
||||||
rect: DOMRect;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDropZone(rect: DOMRect, x: number, y: number): DropPosition {
|
|
||||||
const fx = (x - rect.left) / rect.width;
|
|
||||||
const fy = (y - rect.top) / rect.height;
|
|
||||||
|
|
||||||
if (fx > 0.3 && fx < 0.7 && fy > 0.3 && fy < 0.7) return 'center';
|
|
||||||
|
|
||||||
const edges: [DropPosition, number][] = [
|
|
||||||
['left', fx],
|
|
||||||
['right', 1 - fx],
|
|
||||||
['top', fy],
|
|
||||||
['bottom', 1 - fy],
|
|
||||||
];
|
|
||||||
|
|
||||||
return edges.reduce((min, cur) => (cur[1] < min[1] ? cur : min))[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIndicatorStyle(rect: DOMRect, zone: DropPosition): React.CSSProperties {
|
|
||||||
switch (zone) {
|
|
||||||
case 'center':
|
|
||||||
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
|
||||||
case 'top':
|
|
||||||
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height / 2 };
|
|
||||||
case 'bottom':
|
|
||||||
return { left: rect.left, top: rect.top + rect.height / 2, width: rect.width, height: rect.height / 2 };
|
|
||||||
case 'left':
|
|
||||||
return { left: rect.left, top: rect.top, width: rect.width / 2, height: rect.height };
|
|
||||||
case 'right':
|
|
||||||
return { left: rect.left + rect.width / 2, top: rect.top, width: rect.width / 2, height: rect.height };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DragOverlay = () => {
|
|
||||||
const { dragSourceId, setDragSourceId, onMove } = useWorkspace();
|
|
||||||
const [target, setTarget] = useState<DropTarget | null>(null);
|
|
||||||
const panelRectsRef = useRef<Map<string, DOMRect>>(new Map());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dragSourceId) {
|
|
||||||
panelRectsRef.current.clear();
|
|
||||||
setTarget(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const panels = document.querySelectorAll<HTMLElement>('[data-panel-id]');
|
|
||||||
const map = new Map<string, DOMRect>();
|
|
||||||
panels.forEach((el) => {
|
|
||||||
const id = el.dataset.panelId!;
|
|
||||||
if (id !== dragSourceId) map.set(id, el.getBoundingClientRect());
|
|
||||||
});
|
|
||||||
panelRectsRef.current = map;
|
|
||||||
}, [dragSourceId]);
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback((ev: React.PointerEvent) => {
|
|
||||||
const { clientX: x, clientY: y } = ev;
|
|
||||||
let found: DropTarget | null = null;
|
|
||||||
|
|
||||||
for (const [panelId, rect] of panelRectsRef.current) {
|
|
||||||
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
|
||||||
found = { panelId, zone: getDropZone(rect, x, y), rect };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTarget(found);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(() => {
|
|
||||||
if (dragSourceId && target) {
|
|
||||||
onMove(dragSourceId, target.panelId, target.zone);
|
|
||||||
}
|
|
||||||
setDragSourceId(null);
|
|
||||||
}, [dragSourceId, target, onMove, setDragSourceId]);
|
|
||||||
|
|
||||||
if (!dragSourceId) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 cursor-grabbing"
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
>
|
|
||||||
{target && (
|
|
||||||
<div
|
|
||||||
className="absolute rounded-lg border-2 border-duck-teal/60 bg-duck-teal/15 pointer-events-none"
|
|
||||||
style={{ ...getIndicatorStyle(target.rect, target.zone), transition: 'all 75ms ease-out' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Columns, Rows, Trash2, X } from 'lucide-react';
|
|
||||||
|
|
||||||
type LayoutEditorProps = {
|
|
||||||
panelId: string;
|
|
||||||
hasWidget: boolean;
|
|
||||||
isLastPanel: boolean;
|
|
||||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
|
||||||
onRemove: (panelId: string) => void;
|
|
||||||
onClearApp: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearApp }: LayoutEditorProps) => (
|
|
||||||
<div className="absolute top-1 right-1 flex gap-1 z-10">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
|
||||||
onClick={() => onSplit(panelId, 'horizontal')}
|
|
||||||
title="Split Horizontal"
|
|
||||||
>
|
|
||||||
<Columns size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
|
||||||
onClick={() => onSplit(panelId, 'vertical')}
|
|
||||||
title="Split Vertical"
|
|
||||||
>
|
|
||||||
<Rows size={14} />
|
|
||||||
</button>
|
|
||||||
{hasWidget && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
|
||||||
onClick={onClearApp}
|
|
||||||
title="Clear Widget"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!isLastPanel && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p-1 rounded bg-red-400/10 border border-red-400/30 text-red-400/70 hover:text-red-400 hover:bg-red-400/20 cursor-pointer"
|
|
||||||
onClick={() => onRemove(panelId)}
|
|
||||||
title="Remove Panel"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Pencil, Check } from 'lucide-react';
|
|
||||||
|
|
||||||
type WorkspaceHeaderProps = {
|
|
||||||
name: string;
|
|
||||||
editing: boolean;
|
|
||||||
onToggleEdit: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WorkspaceHeader = ({ name, editing, onToggleEdit }: WorkspaceHeaderProps) => (
|
|
||||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-duck-teal/10">
|
|
||||||
<h2 className="text-sm font-bold text-duck-teal">{name}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`ml-auto flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer ${
|
|
||||||
editing
|
|
||||||
? 'bg-duck-teal/15 text-duck-teal'
|
|
||||||
: 'text-duck-teal/60 hover:text-duck-teal hover:bg-duck-teal/10'
|
|
||||||
}`}
|
|
||||||
onClick={onToggleEdit}
|
|
||||||
>
|
|
||||||
{editing ? <Check size={14} /> : <Pencil size={14} />}
|
|
||||||
{editing ? 'Done' : 'Edit'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,53 +1,18 @@
|
|||||||
import { useCallback } from 'react';
|
import { WorkspaceLayout as BaseWorkspaceLayout } from 'workspaces/react';
|
||||||
import type { LayoutNode, AppRegistryMap, PanelComponents } from './types';
|
import type { ComponentProps } from 'react';
|
||||||
import { updateSizes, collectPanelConfigs } from './layout-utils';
|
|
||||||
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
|
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
|
||||||
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||||
|
|
||||||
type WorkspaceLayoutProps = {
|
/**
|
||||||
layout: LayoutNode;
|
* The inert workspace — same tree, no interaction — with Officer's registry supplied.
|
||||||
onLayoutChange: (layout: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void;
|
*
|
||||||
registry?: AppRegistryMap;
|
* `registry` stays overridable because `DashboardPreview` passes a curated one: a preview shows a template,
|
||||||
components?: PanelComponents;
|
* not whatever this install happens to have.
|
||||||
cwd?: string;
|
*/
|
||||||
noHeader?: boolean;
|
type OfficerWorkspaceLayoutProps = Omit<ComponentProps<typeof BaseWorkspaceLayout>, 'registry'> & {
|
||||||
|
registry?: ComponentProps<typeof BaseWorkspaceLayout>['registry'];
|
||||||
};
|
};
|
||||||
|
|
||||||
const noop = () => {};
|
export const WorkspaceLayout = ({ registry: registryProp, ...props }: OfficerWorkspaceLayoutProps) => {
|
||||||
|
const { registry } = useAppRegistry();
|
||||||
// No mobile props. This renderer had `isMobile`/`mobilePanelId`/`onMobileBack` pass-throughs that none of
|
return <BaseWorkspaceLayout {...props} registry={registryProp ?? registry} />;
|
||||||
// its ten call sites ever set, so the mobile collapse they feed was permanently off here — and being
|
|
||||||
// optional, that read as a feature rather than the dead wiring it was. `WorkspaceView` is where mobile
|
|
||||||
// collapse actually happens, and it passes those to `WorkspaceRenderer` itself.
|
|
||||||
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, cwd, noHeader }: WorkspaceLayoutProps) => {
|
|
||||||
const { registry: globalRegistry } = useAppRegistry();
|
|
||||||
const registry = registryProp ?? globalRegistry;
|
|
||||||
|
|
||||||
// An updater, for the same reason `WorkspaceView`'s eight mutations are: `WorkspaceRenderer` debounces
|
|
||||||
// this by 500 ms, so a tree computed here is 500 ms stale by the time it is written.
|
|
||||||
const handleResized = useCallback(
|
|
||||||
(groupId: string, sizes: number[]) => {
|
|
||||||
onLayoutChange((prev) => updateSizes(prev, groupId, sizes));
|
|
||||||
},
|
|
||||||
[onLayoutChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// No workspace identity: this is the locked/preview renderer, used for job detail panes, settings and
|
|
||||||
// dashboard previews. It is not a dashboard and not a screen, and a panel that asks should be told so
|
|
||||||
// rather than handed something that parses.
|
|
||||||
return (
|
|
||||||
<WorkspaceProvider value={{ workspace: null, cwd: cwd ?? '~', panelConfigs: collectPanelConfigs(layout), ...inertInteraction, isMobile: false, onMobileBack: null }}>
|
|
||||||
<WorkspaceRenderer
|
|
||||||
layout={layout}
|
|
||||||
registry={registry}
|
|
||||||
components={components}
|
|
||||||
noHeader={noHeader}
|
|
||||||
onSetApp={noop}
|
|
||||||
onSplit={noop}
|
|
||||||
onRemove={noop}
|
|
||||||
onResized={handleResized}
|
|
||||||
/>
|
|
||||||
</WorkspaceProvider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,390 +1,46 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } from 'react';
|
import { WorkspaceView as BaseWorkspaceView, type WorkspaceViewProps } from 'workspaces/react';
|
||||||
import { flushSync } from 'react-dom';
|
|
||||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
|
||||||
import { useIsMobile } from 'hooks/useIsMobile';
|
|
||||||
import { useSessionState } from 'hooks/useSessionState';
|
import { useSessionState } from 'hooks/useSessionState';
|
||||||
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
|
|
||||||
import type { DropPosition } from './layout-utils';
|
|
||||||
import {
|
|
||||||
splitPanel,
|
|
||||||
removePanel,
|
|
||||||
setApp,
|
|
||||||
updateSizes,
|
|
||||||
swapPanels,
|
|
||||||
movePanel,
|
|
||||||
countPanels,
|
|
||||||
setZoom,
|
|
||||||
setPanelConfig,
|
|
||||||
collectPanelConfigs,
|
|
||||||
findPanelApp,
|
|
||||||
normalizeLayout,
|
|
||||||
} from './layout-utils';
|
|
||||||
import type { WorkspaceContextValue } from './WorkspaceContext';
|
|
||||||
import { WorkspaceProvider } from './WorkspaceContext';
|
|
||||||
import { parseWorkspaceKey } from './workspace-identity';
|
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
|
||||||
import { usePublishPanelFullscreen } from './panel-fullscreen';
|
|
||||||
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||||
|
import { usePublishPanelFullscreen } from './panel-fullscreen';
|
||||||
|
|
||||||
type WorkspaceViewProps = {
|
/**
|
||||||
workspace: DashboardState;
|
* Officer's workspace: the package, plus the three things it deliberately does not know.
|
||||||
locked?: boolean;
|
*
|
||||||
/**
|
* The `workspaces` package renders a layout and reports changes. It has no registry, no storage and no
|
||||||
* Pin every panel to one of these app types, replacing anything else with `fallback` and writing the
|
* opinion about the rest of the page — which is what makes it usable outside Officer. Everything that IS
|
||||||
* repair back. A locked screen should always pass this: its layout is persisted user state that
|
* about Officer is wired here, once, so all 24 screens keep calling `<WorkspaceView workspace={...} />`
|
||||||
* outlives the code, and an appType the screen no longer renders becomes an empty box the user cannot
|
* exactly as before.
|
||||||
* get out of, because a locked screen has no app picker.
|
*
|
||||||
*
|
* Three couplings, and why each is ours rather than the package's:
|
||||||
* This used to be fourteen character-identical `normalizeLayout` functions in fourteen screens, each
|
*
|
||||||
* with its own `useMemo` and persist-back `useEffect` — which is how nine screens ended up with no
|
* - **The registry.** `appType` -> component is a fact about what Officer ships, including plugins
|
||||||
* guard at all and one with a guard that re-ran forever because it never wrote the correction back.
|
* discovered at build time. A layout system cannot know that list.
|
||||||
*/
|
* - **Fullscreen persistence.** Kept per tab in sessionStorage under `FULLSCREEN_PANEL:<workspace key>`.
|
||||||
appTypes?: { allowed: readonly string[]; fallback: string };
|
* That is a decision about Officer; another host might persist it to a server or not at all.
|
||||||
cwd?: string;
|
* - **Standing the shell down.** The nav header hides for a fullscreen panel, because a panel cannot
|
||||||
root?: string;
|
* paint over it from inside the content region's stacking context. The package reports; we act.
|
||||||
components?: PanelComponents;
|
*/
|
||||||
ephemeral?: EphemeralPanels | null;
|
type OfficerWorkspaceViewProps = Omit<WorkspaceViewProps, 'registry' | 'fullscreenPanelId' | 'onFullscreenPanelChange'>;
|
||||||
mobilePanelId?: string;
|
|
||||||
onMobilePanelChange?: (id: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const noop = () => {};
|
export const WorkspaceView = (props: OfficerWorkspaceViewProps) => {
|
||||||
|
|
||||||
export const WorkspaceView = ({
|
|
||||||
workspace,
|
|
||||||
locked,
|
|
||||||
appTypes,
|
|
||||||
cwd = '~',
|
|
||||||
root,
|
|
||||||
components,
|
|
||||||
ephemeral,
|
|
||||||
mobilePanelId,
|
|
||||||
onMobilePanelChange,
|
|
||||||
}: WorkspaceViewProps) => {
|
|
||||||
const { registry } = useAppRegistry();
|
const { registry } = useAppRegistry();
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
// Deliberately not memoised. `normalizeLayout` returns its input by reference when nothing needed
|
|
||||||
// fixing, so the identity everything downstream depends on is already stable; a `useMemo` here would
|
|
||||||
// only add a dependency array to get wrong, and the callers pass a fresh object literal for `appTypes`
|
|
||||||
// anyway, which would defeat it. The walk is a handful of nodes.
|
|
||||||
const layout = appTypes ? normalizeLayout(workspace.value, appTypes.allowed, appTypes.fallback) : workspace.value;
|
|
||||||
const onLayoutChange = workspace.setValue;
|
|
||||||
|
|
||||||
// Write the repair back, so a layout is fixed once rather than re-fixed on every mount for the rest of
|
|
||||||
// its life. Terminates because the normalised tree normalises to itself.
|
|
||||||
const rawLayout = workspace.value;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!workspace.isLoaded || layout === rawLayout) return;
|
|
||||||
onLayoutChange(layout);
|
|
||||||
}, [workspace.isLoaded, layout, rawLayout, onLayoutChange]);
|
|
||||||
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
|
||||||
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
|
||||||
// Per tab and per dashboard: putting the chat panel full screen and refreshing should come back full
|
|
||||||
// screen, while a second window on the same screen keeps its own idea of what is blown up. A stored id
|
|
||||||
// whose panel has since been removed simply fills nothing — this is a style toggle on the panel itself,
|
|
||||||
// so there is nothing to strand.
|
|
||||||
//
|
|
||||||
// Keyed `FULLSCREEN_PANEL`, not the `MAXIMIZED_PANEL` this used to be. The old key briefly held a
|
// Keyed `FULLSCREEN_PANEL`, not the `MAXIMIZED_PANEL` this used to be. The old key briefly held a
|
||||||
// `{ id, mode }` pair for the two depths; renaming it means a tab open across that change reads nothing
|
// `{ id, mode }` pair for two depths; renaming it means a tab open across that change reads nothing here
|
||||||
// here rather than reading an object where a string is expected.
|
// rather than reading an object where a string is expected.
|
||||||
const fullscreenKey = `FULLSCREEN_PANEL:${workspace.key}`;
|
const [fullscreenPanelId, setFullscreenPanelId] = useSessionState<string | null>(
|
||||||
const [fullscreenPanelId, setFullscreenPanelId] = useSessionState<string | null>(fullscreenKey, null);
|
`FULLSCREEN_PANEL:${props.workspace.key}`,
|
||||||
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// The shell hides the nav header for it — a panel cannot paint over that from inside the content
|
|
||||||
// region's stacking context. See `panel-fullscreen.ts`.
|
|
||||||
usePublishPanelFullscreen(fullscreenPanelId !== null);
|
usePublishPanelFullscreen(fullscreenPanelId !== null);
|
||||||
|
|
||||||
const setFullscreenAnimated = useCallback(
|
|
||||||
(id: string | null) => {
|
|
||||||
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
|
|
||||||
const panelId = fullscreenPanelId ?? id;
|
|
||||||
if (doc.startViewTransition && panelId) {
|
|
||||||
setTransitioningPanelId(panelId);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const transition = doc.startViewTransition(() => flushSync(() => setFullscreenPanelId(id)));
|
|
||||||
transition.finished.finally(() => setTransitioningPanelId(null));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setFullscreenPanelId(id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fullscreenPanelId, setFullscreenPanelId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// What each panel wants done when it is closed, by panel id, stamped with the workspace it was
|
|
||||||
// registered on. A ref rather than state: nothing renders from it, and a panel registering during its
|
|
||||||
// own mount effect must not schedule a render of the whole workspace.
|
|
||||||
//
|
|
||||||
// Nothing is ever unregistered. A handler is dropped when it fires and replaced when the same panel id
|
|
||||||
// registers again; a panel that merely unmounted keeps its handler, because "unmounted" is precisely
|
|
||||||
// the signal this exists to avoid trusting. The stamp is what keeps that from leaking across
|
|
||||||
// dashboards — the same panel id can exist on two of them, and a handler from the one you navigated
|
|
||||||
// away from must not fire for the one you are on.
|
|
||||||
const closeHandlers = useRef(new Map<string, { workspaceKey: string; run: () => void }>());
|
|
||||||
const workspaceKeyRef = useRef(workspace.key);
|
|
||||||
workspaceKeyRef.current = workspace.key;
|
|
||||||
|
|
||||||
const registerPanelClose = useCallback((panelId: string, run: () => void) => {
|
|
||||||
closeHandlers.current.set(panelId, { workspaceKey: workspaceKeyRef.current, run });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const firePanelClose = useCallback((panelId: string) => {
|
|
||||||
const entry = closeHandlers.current.get(panelId);
|
|
||||||
if (!entry) return;
|
|
||||||
closeHandlers.current.delete(panelId);
|
|
||||||
if (entry.workspaceKey !== workspaceKeyRef.current) return;
|
|
||||||
// A handler that throws must not stop the panel from closing — the layout write is the user's
|
|
||||||
// instruction, and the cleanup is a best effort on top of it.
|
|
||||||
try {
|
|
||||||
entry.run();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`panel close handler for ${panelId} threw`, err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Every mutation goes through `onLayoutChange` as an UPDATER, never as a computed tree.
|
|
||||||
//
|
|
||||||
// Each of these used to close over `layout` as it was when the callback was made, and two of the paths
|
|
||||||
// are not immediate: the resize debounce fires 500 ms after the drag began, and a window resize fires
|
|
||||||
// `onLayout` on every group at once. So the second write was computed from a tree that predated the
|
|
||||||
// first and silently undid it — remove a panel just after dragging a splitter and it came back. That is
|
|
||||||
// worse than it sounds now that panel identity lives in the layout: the resurrected tree carries an
|
|
||||||
// older `config`, so a panel that was just given an agent's name reverts to anonymous, and the agent it
|
|
||||||
// addresses is no longer reachable through it.
|
|
||||||
//
|
|
||||||
// The guards below still test the rendered `layout` — they only decide whether a write is worth making,
|
|
||||||
// and being one render stale there costs a redundant no-op write at worst.
|
|
||||||
//
|
|
||||||
// `handleSetApp` and `handleRemove` additionally fire the closing panel's `usePanelClose` handler.
|
|
||||||
// They are the only two places that do, and they are the only two that know a close was *asked for* —
|
|
||||||
// a drag, a swap and a mobile panel switch all tear a panel's component down without closing anything.
|
|
||||||
const handleSetApp = useCallback(
|
|
||||||
(panelId: string, appType: string | null) => {
|
|
||||||
// Re-picking the app that is already there is not a close: `setApp` keeps the config and the app
|
|
||||||
// never goes away. Every other transition retires whatever was running.
|
|
||||||
if (findPanelApp(layout, panelId) !== appType) firePanelClose(panelId);
|
|
||||||
onLayoutChange((prev) => setApp(prev, panelId, appType));
|
|
||||||
},
|
|
||||||
[layout, onLayoutChange, firePanelClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSplit = useCallback(
|
|
||||||
(panelId: string, direction: 'horizontal' | 'vertical') => {
|
|
||||||
onLayoutChange((prev) => splitPanel(prev, panelId, direction));
|
|
||||||
},
|
|
||||||
[onLayoutChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemove = useCallback(
|
|
||||||
(panelId: string) => {
|
|
||||||
if (countPanels(layout) <= 1) return;
|
|
||||||
// Fired before the write, from the rendered tree, rather than from inside the updater — an updater
|
|
||||||
// has to stay pure and may run twice. The updater's own guard can therefore decline a removal we
|
|
||||||
// already announced, if a concurrent write shrank the tree to one panel in between. That costs a
|
|
||||||
// cleanup for a panel still on screen; it needs two writes to race inside one debounce window, and
|
|
||||||
// the alternative is a side effect in an updater.
|
|
||||||
firePanelClose(panelId);
|
|
||||||
onLayoutChange((prev) => (countPanels(prev) <= 1 ? prev : removePanel(prev, panelId)));
|
|
||||||
},
|
|
||||||
[layout, onLayoutChange, firePanelClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleResized = useCallback(
|
|
||||||
(groupId: string, sizes: number[]) => {
|
|
||||||
onLayoutChange((prev) => updateSizes(prev, groupId, sizes));
|
|
||||||
},
|
|
||||||
[onLayoutChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetZoom = useCallback(
|
|
||||||
(panelId: string, zoom: number) => {
|
|
||||||
if (setZoom(layout, panelId, zoom) === layout) return;
|
|
||||||
onLayoutChange((prev) => setZoom(prev, panelId, zoom));
|
|
||||||
},
|
|
||||||
[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]);
|
|
||||||
|
|
||||||
// Memoised on the key, not recomputed inline: the provider value is a fresh object every render
|
|
||||||
// anyway, but this one ends up in consumers' dependency arrays (`useAgentPanel`'s query key, the
|
|
||||||
// terminal state key). A new identity per render is how `useClient()` silently disabled every
|
|
||||||
// playback report in the Jellyfin player — same shape, so don't reproduce it here.
|
|
||||||
const identity = useMemo(() => parseWorkspaceKey(workspace.key), [workspace.key]);
|
|
||||||
|
|
||||||
const handleSetPanelConfig = useCallback(
|
|
||||||
(panelId: string, config: PanelConfig | undefined) => {
|
|
||||||
if (setPanelConfig(layout, panelId, config) === layout) return;
|
|
||||||
onLayoutChange((prev) => setPanelConfig(prev, panelId, config));
|
|
||||||
},
|
|
||||||
[layout, onLayoutChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSwap = useCallback(
|
|
||||||
(sourceId: string, targetId: string) => {
|
|
||||||
onLayoutChange((prev) => swapPanels(prev, sourceId, targetId));
|
|
||||||
setSwapSourceId(null);
|
|
||||||
},
|
|
||||||
[onLayoutChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMove = useCallback(
|
|
||||||
(sourceId: string, targetId: string, position: DropPosition) => {
|
|
||||||
onLayoutChange((prev) => movePanel(prev, sourceId, targetId, position));
|
|
||||||
setDragSourceId(null);
|
|
||||||
},
|
|
||||||
[onLayoutChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const startDrag = useCallback((id: string | null) => {
|
|
||||||
setSwapSourceId(null);
|
|
||||||
setDragSourceId(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Escape cancels a *pending* swap or drag — modes you are halfway through, where it is the only way
|
|
||||||
// out. Maximize is not one of those: it is a state you chose and that now outlives a refresh, so a key
|
|
||||||
// press meant for whatever you are typing in should not throw it away. Restore is the panel's own
|
|
||||||
// button.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!swapSourceId && !dragSourceId) return;
|
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
|
||||||
if (ev.key === 'Escape') {
|
|
||||||
setSwapSourceId(null);
|
|
||||||
setDragSourceId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
|
||||||
}, [swapSourceId, dragSourceId]);
|
|
||||||
|
|
||||||
const ephemeralPanelRef = useRef<ComponentRef<typeof ResizablePanel>>(null);
|
|
||||||
const isEphemeralOpen = !!ephemeral;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const panel = ephemeralPanelRef.current;
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
// `react-resizable-panels` asserts rather than no-ops when it is asked to resize a group it has not
|
|
||||||
// laid out yet — and an assert thrown from an effect aborts the commit, which here means the whole
|
|
||||||
// dashboard rather than the pane that could not be sized. The pane is a file preview; the panels
|
|
||||||
// behind it are a night's work. Only the ephemeral pane's own geometry is at stake, and the next
|
|
||||||
// render tries again.
|
|
||||||
try {
|
|
||||||
if (isEphemeralOpen) {
|
|
||||||
panel.resize(100 - (ephemeral.defaultBaseSize ?? 40));
|
|
||||||
} else {
|
|
||||||
panel.collapse();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[workspace] could not size the ephemeral pane yet', err);
|
|
||||||
}
|
|
||||||
}, [isEphemeralOpen, ephemeral?.defaultBaseSize]);
|
|
||||||
|
|
||||||
const onMobileBack = useMemo(() => {
|
|
||||||
if (!isMobile) return null;
|
|
||||||
if (mobilePanelId && onMobilePanelChange) return () => onMobilePanelChange(null);
|
|
||||||
if (isEphemeralOpen && ephemeral?.onClose) return ephemeral.onClose;
|
|
||||||
return null;
|
|
||||||
}, [isMobile, mobilePanelId, onMobilePanelChange, isEphemeralOpen, ephemeral?.onClose]);
|
|
||||||
|
|
||||||
// A fresh object literal here re-rendered every `useWorkspace()` consumer in every panel on every
|
|
||||||
// `WorkspaceView` render — including each frame of a maximize animation, and including renders that
|
|
||||||
// changed nothing a panel can see (the ephemeral pane opening, a mobile panel switch). Memoised, a panel
|
|
||||||
// only re-renders when something it is actually given changes.
|
|
||||||
const contextValue = useMemo<WorkspaceContextValue>(
|
|
||||||
() => ({
|
|
||||||
workspace: identity,
|
|
||||||
cwd,
|
|
||||||
root,
|
|
||||||
panelConfigs,
|
|
||||||
setPanelConfig: locked ? noop : handleSetPanelConfig,
|
|
||||||
registerPanelClose,
|
|
||||||
swapSourceId,
|
|
||||||
setSwapSourceId,
|
|
||||||
onSwap: handleSwap,
|
|
||||||
dragSourceId,
|
|
||||||
setDragSourceId: startDrag,
|
|
||||||
onMove: handleMove,
|
|
||||||
onSetZoom: handleSetZoom,
|
|
||||||
fullscreenPanelId,
|
|
||||||
setFullscreenPanelId: setFullscreenAnimated,
|
|
||||||
transitioningPanelId,
|
|
||||||
isMobile,
|
|
||||||
onMobileBack,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
identity,
|
|
||||||
cwd,
|
|
||||||
root,
|
|
||||||
panelConfigs,
|
|
||||||
locked,
|
|
||||||
handleSetPanelConfig,
|
|
||||||
registerPanelClose,
|
|
||||||
swapSourceId,
|
|
||||||
handleSwap,
|
|
||||||
dragSourceId,
|
|
||||||
startDrag,
|
|
||||||
handleMove,
|
|
||||||
handleSetZoom,
|
|
||||||
fullscreenPanelId,
|
|
||||||
setFullscreenAnimated,
|
|
||||||
transitioningPanelId,
|
|
||||||
isMobile,
|
|
||||||
onMobileBack,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!workspace.isLoaded) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceProvider value={contextValue}>
|
<BaseWorkspaceView
|
||||||
{isMobile && isEphemeralOpen && ephemeral ? (
|
{...props}
|
||||||
<WorkspaceRenderer
|
registry={registry}
|
||||||
layout={ephemeral.layout}
|
fullscreenPanelId={fullscreenPanelId}
|
||||||
registry={registry}
|
onFullscreenPanelChange={setFullscreenPanelId}
|
||||||
components={ephemeral.components}
|
/>
|
||||||
isMobile={isMobile}
|
|
||||||
onSetApp={noop}
|
|
||||||
onSplit={noop}
|
|
||||||
onRemove={noop}
|
|
||||||
onResized={noop}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
|
||||||
<ResizablePanel defaultSize={100} minSize={15}>
|
|
||||||
<WorkspaceRenderer
|
|
||||||
layout={layout}
|
|
||||||
registry={registry}
|
|
||||||
components={components}
|
|
||||||
interactive
|
|
||||||
locked={locked}
|
|
||||||
isMobile={isMobile}
|
|
||||||
mobilePanelId={mobilePanelId}
|
|
||||||
onSetApp={locked ? noop : handleSetApp}
|
|
||||||
onSplit={locked ? noop : handleSplit}
|
|
||||||
onRemove={locked ? noop : handleRemove}
|
|
||||||
onResized={handleResized}
|
|
||||||
/>
|
|
||||||
</ResizablePanel>
|
|
||||||
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
|
|
||||||
<ResizablePanel ref={ephemeralPanelRef} collapsible collapsedSize={0} defaultSize={0} minSize={15}>
|
|
||||||
{isEphemeralOpen ? (
|
|
||||||
<WorkspaceRenderer
|
|
||||||
layout={ephemeral.layout}
|
|
||||||
registry={registry}
|
|
||||||
components={ephemeral.components}
|
|
||||||
onSetApp={noop}
|
|
||||||
onSplit={noop}
|
|
||||||
onRemove={noop}
|
|
||||||
onResized={noop}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
)}
|
|
||||||
</WorkspaceProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
export type {
|
/**
|
||||||
LayoutNode,
|
* Officer's workspace surface, unchanged for its 24 screens.
|
||||||
LayoutGroup,
|
*
|
||||||
LayoutPanel,
|
* The framework itself now lives in the `workspaces` package, which knows nothing about Officer. This
|
||||||
DashboardDefinition,
|
* barrel keeps the old import paths working and adds back the three things that are ours: the app
|
||||||
DashboardState,
|
* registry, fullscreen persistence, and per-panel memory in dashboard state.
|
||||||
AppRegistryMap,
|
*
|
||||||
AppRegistryEntry,
|
* New code inside Officer can import from `workspaces` directly. Anything needing the registry or
|
||||||
PanelComponents,
|
* persistence should come through here instead.
|
||||||
PanelComponentEntry,
|
*/
|
||||||
EphemeralPanels,
|
|
||||||
HomeRoot,
|
// The layout model and its operations — pure, framework-free, and no longer Officer's to own.
|
||||||
PanelConfig,
|
export type { LayoutNode, LayoutGroup, LayoutPanel, PanelConfig, DropPosition } from 'workspaces/core';
|
||||||
} from './types';
|
|
||||||
export type { DropPosition } from './layout-utils';
|
|
||||||
export {
|
export {
|
||||||
uid,
|
uid,
|
||||||
createDefaultLayout,
|
createDefaultLayout,
|
||||||
@@ -22,21 +20,48 @@ export {
|
|||||||
updateSizes,
|
updateSizes,
|
||||||
swapPanels,
|
swapPanels,
|
||||||
movePanel,
|
movePanel,
|
||||||
pruneEmptyPanels,
|
setZoom,
|
||||||
|
setPanelConfig,
|
||||||
|
findPanelApp,
|
||||||
|
collectPanelConfigs,
|
||||||
countPanels,
|
countPanels,
|
||||||
hasAnyApp,
|
hasAnyApp,
|
||||||
setPanelConfig,
|
|
||||||
collectPanelConfigs,
|
|
||||||
findPanelApp,
|
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
} from './layout-utils';
|
pruneEmptyPanels,
|
||||||
export type { WorkspaceIdentity } from './workspace-identity';
|
ZOOM_MIN,
|
||||||
export { parseWorkspaceKey } from './workspace-identity';
|
ZOOM_MAX,
|
||||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
ZOOM_STEP,
|
||||||
export { cwdToPath } from './cwd-path';
|
} from 'workspaces/core';
|
||||||
export { usePanelFullscreen } from './panel-fullscreen';
|
export type { WorkspaceIdentity } from 'workspaces/core';
|
||||||
export { usePanelConfig } from './usePanelConfig';
|
export { parseWorkspaceKey } from 'workspaces/core';
|
||||||
export { usePanelClose } from './usePanelClose';
|
|
||||||
export { usePanelMemory, panelStateKey } from './use-panel-memory';
|
// The renderer, and the hooks a panel is allowed to use.
|
||||||
|
export {
|
||||||
|
WorkspaceRenderer,
|
||||||
|
PanelSlot,
|
||||||
|
WorkspaceProvider,
|
||||||
|
useWorkspace,
|
||||||
|
usePanelConfig,
|
||||||
|
usePanelClose,
|
||||||
|
} from 'workspaces/react';
|
||||||
|
export type { WorkspaceContextValue } from 'workspaces/react';
|
||||||
|
|
||||||
|
// Officer's wrappers — the package plus the registry, fullscreen persistence and the shell handshake.
|
||||||
export { WorkspaceView } from './WorkspaceView';
|
export { WorkspaceView } from './WorkspaceView';
|
||||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||||
|
|
||||||
|
// Officer-only, and staying: what a dashboard is, where a panel's scrap state lives, how the shell learns
|
||||||
|
// a panel went fullscreen, and how `~` becomes a path.
|
||||||
|
export type {
|
||||||
|
DashboardDefinition,
|
||||||
|
DashboardState,
|
||||||
|
HomeRoot,
|
||||||
|
AppRegistryEntry,
|
||||||
|
AppRegistryMap,
|
||||||
|
PanelComponents,
|
||||||
|
PanelComponentEntry,
|
||||||
|
EphemeralPanels,
|
||||||
|
} from './types';
|
||||||
|
export { usePanelMemory, panelStateKey } from './use-panel-memory';
|
||||||
|
export { usePanelFullscreen, usePublishPanelFullscreen } from './panel-fullscreen';
|
||||||
|
export { cwdToPath } from './cwd-path';
|
||||||
|
|||||||
@@ -1,38 +1,21 @@
|
|||||||
import type { ComponentType, ReactNode } from 'react';
|
/**
|
||||||
import type { LucideIcon } from 'lucide-react';
|
* Officer's workspace types.
|
||||||
|
*
|
||||||
export type LayoutGroup = {
|
* The layout model itself moved to the `workspaces` package — it is no longer Officer's to define. What
|
||||||
type: 'group';
|
* remains here is what only Officer has: what a dashboard IS, as opposed to what a layout is.
|
||||||
id: string;
|
*/
|
||||||
direction: 'horizontal' | 'vertical';
|
export type {
|
||||||
children: { node: LayoutNode; size: number }[];
|
LayoutNode,
|
||||||
};
|
LayoutGroup,
|
||||||
|
LayoutPanel,
|
||||||
export type LayoutPanel = {
|
PanelConfig,
|
||||||
type: 'panel';
|
DashboardState,
|
||||||
id: string;
|
AppRegistryEntry,
|
||||||
appType: string | null;
|
AppRegistryMap,
|
||||||
fitContent?: boolean;
|
PanelComponents,
|
||||||
// Per-panel content scale (1 = 100%). Lives on the node rather than in its own useDashboardState key
|
PanelComponentEntry,
|
||||||
// because the layout is already persisted per panel — a separate key would seed a row per panel on
|
EphemeralPanels,
|
||||||
// mount (useDashboardState seeds defaults to the server), and would need its own dispatcher branch.
|
} from 'workspaces/react';
|
||||||
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';
|
export type HomeRoot = 'home' | '~' | 'officer.dev';
|
||||||
|
|
||||||
@@ -44,47 +27,3 @@ export type DashboardDefinition = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
templateIdx?: number;
|
templateIdx?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardState = {
|
|
||||||
key: string;
|
|
||||||
value: LayoutNode;
|
|
||||||
setValue: (value: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void;
|
|
||||||
isLoaded: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AppRegistryEntry = {
|
|
||||||
name: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
component: ComponentType<{ panelId: string }>;
|
|
||||||
header?: ComponentType<{ panelId: string }>;
|
|
||||||
provider?: ComponentType<{ panelId: string; children: ReactNode }>;
|
|
||||||
transparent?: boolean;
|
|
||||||
availableOnPanel?: boolean;
|
|
||||||
// Opt out of the panel zoom control. For apps that scale badly under a CSS transform: a terminal
|
|
||||||
// (its own font sizing, and scale blurs the glyph grid) or a remote-desktop canvas (noVNC does its
|
|
||||||
// own autoscale, and a wrapping transform desyncs its pointer mapping).
|
|
||||||
zoomable?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AppRegistryMap = Record<string, AppRegistryEntry>;
|
|
||||||
|
|
||||||
// A panel supplied by a screen rather than by the registry. `PanelSlot` renders all three of these with
|
|
||||||
// `panelId`, exactly as it does the registry's — they were typed with no props at all, so a
|
|
||||||
// `components`-supplied panel was handed an id it could not see, and a screen wanting one had to reach
|
|
||||||
// for `useParams` or a channel instead. A component that takes no props is still assignable here, so the
|
|
||||||
// existing screens are unaffected.
|
|
||||||
export type PanelComponentEntry = {
|
|
||||||
component: ComponentType<{ panelId: string }>;
|
|
||||||
header?: ComponentType<{ panelId: string }>;
|
|
||||||
provider?: ComponentType<{ panelId: string; children: ReactNode }>;
|
|
||||||
onClose?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PanelComponents = Record<string, ComponentType<{ panelId: string }> | PanelComponentEntry>;
|
|
||||||
|
|
||||||
export type EphemeralPanels = {
|
|
||||||
layout: LayoutNode;
|
|
||||||
components: PanelComponents;
|
|
||||||
defaultBaseSize?: number;
|
|
||||||
onClose?: () => void;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { usePanelClose } from './usePanelClose';
|
import { usePanelClose } from 'workspaces/react';
|
||||||
import type { WorkspaceIdentity } from './workspace-identity';
|
import type { WorkspaceIdentity } from 'workspaces/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A panel's own scrap of state, remembered across a reload.
|
* A panel's own scrap of state, remembered across a reload.
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
|
||||||
import { parseWorkspaceKey } from './workspace-identity';
|
|
||||||
import { terminalStateKey } from '../../apps/Terminal/state-key';
|
|
||||||
|
|
||||||
describe('parseWorkspaceKey', () => {
|
|
||||||
test('reads a dashboard key', () => {
|
|
||||||
expect(parseWorkspaceKey('ws-layout-agent-mvp')).toEqual({
|
|
||||||
kind: 'dashboard',
|
|
||||||
id: 'agent-mvp',
|
|
||||||
key: 'ws-layout-agent-mvp',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reads a screen key', () => {
|
|
||||||
expect(parseWorkspaceKey('screens/email')).toEqual({ kind: 'screen', id: 'email', key: 'screens/email' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Both halves may contain dashes and slashes; only the first prefix is structural.
|
|
||||||
test('keeps the whole remainder as the id', () => {
|
|
||||||
expect(parseWorkspaceKey('ws-layout-my-dash-2')?.id).toBe('my-dash-2');
|
|
||||||
expect(parseWorkspaceKey('screens/soulseek-v2')?.id).toBe('soulseek-v2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is null for anything that is neither', () => {
|
|
||||||
expect(parseWorkspaceKey(null)).toBeNull();
|
|
||||||
expect(parseWorkspaceKey(undefined)).toBeNull();
|
|
||||||
expect(parseWorkspaceKey('')).toBeNull();
|
|
||||||
expect(parseWorkspaceKey('email')).toBeNull();
|
|
||||||
// The prefixes alone name nothing.
|
|
||||||
expect(parseWorkspaceKey('ws-layout-')).toBeNull();
|
|
||||||
expect(parseWorkspaceKey('screens/')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('terminalStateKey', () => {
|
|
||||||
// The regression this rule exists for: a wrapper that passed the raw key through produced
|
|
||||||
// `ws-host-terminals-ws-layout-my-dash`, which the server parsed back out as a dashboard id and created.
|
|
||||||
test('strips the layout prefix rather than nesting it', () => {
|
|
||||||
const key = terminalStateKey('host-terminals', parseWorkspaceKey('ws-layout-my-dash'));
|
|
||||||
expect(key).toBe('ws-host-terminals-my-dash');
|
|
||||||
expect(key).not.toContain('ws-layout');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('screens and unknown workspaces share the defaults row', () => {
|
|
||||||
expect(terminalStateKey('terminals', parseWorkspaceKey('screens/chat'))).toBe('ws-terminals-default');
|
|
||||||
expect(terminalStateKey('terminals', null)).toBe('ws-terminals-default');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('carries the panel-state prefixes the dispatcher allow-lists', () => {
|
|
||||||
const dashboard = parseWorkspaceKey('ws-layout-d1');
|
|
||||||
expect(terminalStateKey('tmux', dashboard)).toBe('ws-tmux-d1');
|
|
||||||
expect(terminalStateKey('nvim', dashboard)).toBe('ws-nvim-d1');
|
|
||||||
expect(terminalStateKey('claude-code', dashboard)).toBe('ws-claude-code-d1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# workspaces
|
||||||
|
|
||||||
|
A tiling workspace and panel layout system. **Layout in, changes out.**
|
||||||
|
|
||||||
|
It renders a tree of panels and lets a person split, resize, move, close and fullscreen them. It performs
|
||||||
|
no IO, imports no panel, and has no opinion about the rest of your page. Where the layout is stored — a
|
||||||
|
server, localStorage, nowhere — is entirely yours.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<WorkspaceView
|
||||||
|
workspace={{ key: 'my-layout', value: layout, setValue: setLayout, isLoaded: true }}
|
||||||
|
registry={{ notes: { name: 'Notes', icon: NotesIcon, component: NotesPanel } }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole contract. `setValue` receives an **updater** — `(prev) => next` — so overlapping writes
|
||||||
|
compose instead of clobbering each other. Two paths are not immediate (the resize debounce fires 500ms
|
||||||
|
after a drag, and a window resize fires every group at once), so a write computed from a stale tree would
|
||||||
|
silently undo the write before it.
|
||||||
|
|
||||||
|
## Two halves
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `workspaces/core` | the tree and every operation on it. Pure, no dependencies, **no React** |
|
||||||
|
| `workspaces/react` | one renderer for it |
|
||||||
|
|
||||||
|
The split is deliberate. Every mutation is `(tree, args) => tree` — `splitPanel`, `removePanel`,
|
||||||
|
`movePanel`, `swapPanels`, `setApp`, `setZoom`, `setPanelConfig`, `updateSizes`, `normalizeLayout` — and
|
||||||
|
none of them knows what a component is. A second renderer reuses all of it untouched.
|
||||||
|
|
||||||
|
## The layout
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type LayoutGroup = { type: 'group'; id: string; direction: 'horizontal' | 'vertical';
|
||||||
|
children: { node: LayoutNode; size: number }[] };
|
||||||
|
type LayoutPanel = { type: 'panel'; id: string; appType: string | null;
|
||||||
|
fitContent?: boolean; zoom?: number; config?: PanelConfig };
|
||||||
|
```
|
||||||
|
|
||||||
|
`size` is a percentage within its group. `config` is opaque — the framework stores, moves and deletes it
|
||||||
|
and never reads a field. It travels with the panel through move and swap, because an app's identity is
|
||||||
|
both what it is and how it is configured.
|
||||||
|
|
||||||
|
## What the host supplies
|
||||||
|
|
||||||
|
**`registry`** — `appType` → component. This package never imports a panel.
|
||||||
|
|
||||||
|
**`workspace`** — the layout and a way to change it. Persist it however you like, or not at all.
|
||||||
|
|
||||||
|
**`fullscreenPanelId` / `onFullscreenPanelChange`** — optional. Fullscreen IS layout state, so whether it
|
||||||
|
survives a reload is your decision; leave them out and it lives in local state. Officer keeps it in
|
||||||
|
sessionStorage per tab, which is a fact about Officer rather than about workspaces.
|
||||||
|
|
||||||
|
## Panel-facing hooks
|
||||||
|
|
||||||
|
`useWorkspace()`, `usePanelConfig(panelId)`, `usePanelClose(panelId, handler)`.
|
||||||
|
|
||||||
|
`usePanelClose` fires when a close was **asked for** — not on the unmounts a drag, a swap or a mobile
|
||||||
|
switch cause.
|
||||||
|
|
||||||
|
## Not here, on purpose
|
||||||
|
|
||||||
|
No storage, no network, no auth, no routing, no state library. Panel-to-panel communication is currently
|
||||||
|
the host's too; if it moves in, it will be an in-memory bus configured from outside, never a store.
|
||||||
|
|
||||||
|
## Phase 2
|
||||||
|
|
||||||
|
A framework-agnostic renderer over the same `core`. What will not port for free is the resize
|
||||||
|
*interaction* — drag handles, pointer maths, constraints — which `react-resizable-panels` supplies today.
|
||||||
|
That is the bounded cost, and it is why the tree logic was kept out of the components.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "workspaces",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"description": "A tiling workspace and panel layout system. Layout in, changes out \u2014 persistence is the host's.",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./core": "./src/core/index.ts",
|
||||||
|
"./react": "./src/react/index.ts"
|
||||||
|
},
|
||||||
|
"_dependencies_note": "Only the one that had to be pinned. This repository carries TWO versions of react-resizable-panels \u2014 the root asks for ^4.0.15 (4.6.5) and src/workspaces/components for ^2.1.7 (2.1.9) \u2014 and this code was written against 2.x, whose API differs: v4 has no PanelGroup export, no `order` prop and no `ref` on Panel. Inheriting the root resolution produced dozens of type errors that looked like a broken extraction and were a version mismatch. Everything else (react, react-dom, radix, lucide, clsx, tailwind-merge) is deliberately NOT declared while this lives in the monorepo: declaring them at versions that did not match the root produced a second copy of react under this package, which breaks hooks at runtime and is invisible until something renders. Publishing standalone means declaring them all, aligned with the host.",
|
||||||
|
"dependencies": {
|
||||||
|
"react-resizable-panels": "^2.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { parseWorkspaceKey } from './identity';
|
||||||
|
|
||||||
|
describe('parseWorkspaceKey', () => {
|
||||||
|
test('reads a dashboard key', () => {
|
||||||
|
expect(parseWorkspaceKey('ws-layout-agent-mvp')).toEqual({
|
||||||
|
kind: 'dashboard',
|
||||||
|
id: 'agent-mvp',
|
||||||
|
key: 'ws-layout-agent-mvp',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads a screen key', () => {
|
||||||
|
expect(parseWorkspaceKey('screens/email')).toEqual({ kind: 'screen', id: 'email', key: 'screens/email' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both halves may contain dashes and slashes; only the first prefix is structural.
|
||||||
|
test('keeps the whole remainder as the id', () => {
|
||||||
|
expect(parseWorkspaceKey('ws-layout-my-dash-2')?.id).toBe('my-dash-2');
|
||||||
|
expect(parseWorkspaceKey('screens/soulseek-v2')?.id).toBe('soulseek-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is null for anything that is neither', () => {
|
||||||
|
expect(parseWorkspaceKey(null)).toBeNull();
|
||||||
|
expect(parseWorkspaceKey(undefined)).toBeNull();
|
||||||
|
expect(parseWorkspaceKey('')).toBeNull();
|
||||||
|
expect(parseWorkspaceKey('email')).toBeNull();
|
||||||
|
// The prefixes alone name nothing.
|
||||||
|
expect(parseWorkspaceKey('ws-layout-')).toBeNull();
|
||||||
|
expect(parseWorkspaceKey('screens/')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* The framework-agnostic half: the tree and every operation on it.
|
||||||
|
*
|
||||||
|
* Nothing here imports React, and nothing here performs IO. A change is `(tree, args) => tree`, which is
|
||||||
|
* what lets a host persist it however it likes — and what will let a non-React renderer reuse all of it
|
||||||
|
* untouched.
|
||||||
|
*/
|
||||||
|
export type { LayoutNode, LayoutGroup, LayoutPanel, PanelConfig } from './types';
|
||||||
|
export * from './layout';
|
||||||
|
export * from './identity';
|
||||||
+1
-1
@@ -16,7 +16,7 @@ import {
|
|||||||
splitPanel,
|
splitPanel,
|
||||||
swapPanels,
|
swapPanels,
|
||||||
updateSizes,
|
updateSizes,
|
||||||
} from './layout-utils';
|
} from './layout';
|
||||||
|
|
||||||
// These functions are the whole of the layout's behaviour, and since `e588524` they also carry a panel's
|
// These functions are the whole of the layout's behaviour, and since `e588524` they also carry a panel's
|
||||||
// *identity* — the opaque `config` an app uses to remember what it is. `apps/Chat` stores an agent's name
|
// *identity* — the opaque `config` an app uses to remember what it is. `apps/Chat` stores an agent's name
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* The layout tree. Plain data, no dependencies, no framework.
|
||||||
|
*
|
||||||
|
* This is what a host persists and what phase 2 will render without React. Everything else in this package
|
||||||
|
* is a way of drawing it or changing it; a workspace IS one of these.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LayoutGroup = {
|
||||||
|
type: 'group';
|
||||||
|
id: string;
|
||||||
|
direction: 'horizontal' | 'vertical';
|
||||||
|
children: { node: LayoutNode; size: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutPanel = {
|
||||||
|
type: 'panel';
|
||||||
|
id: string;
|
||||||
|
/** Which app this panel is running. Resolved by the HOST — this package never imports a panel. */
|
||||||
|
appType: string | null;
|
||||||
|
fitContent?: boolean;
|
||||||
|
/** Per-panel content scale (1 = 100%). On the node because the node is the thing already persisted. */
|
||||||
|
zoom?: number;
|
||||||
|
/**
|
||||||
|
* Opaque per-panel settings owned by whichever app the panel is running. This package stores, moves and
|
||||||
|
* deletes it and never reads a field — the shape is the app's business.
|
||||||
|
*
|
||||||
|
* It travels with the panel through swap and move, 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;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* A tiling workspace and panel layout system.
|
||||||
|
*
|
||||||
|
* Layout in, changes out. It renders a tree of panels, lets a person split, resize, move, close and
|
||||||
|
* fullscreen them, and calls back with the new tree. Where that tree is stored — a server, localStorage,
|
||||||
|
* nowhere — is entirely the host's business.
|
||||||
|
*
|
||||||
|
* `workspaces/core` is the tree and its operations: pure, dependency-free, framework-free.
|
||||||
|
* `workspaces/react` is one renderer for it.
|
||||||
|
*/
|
||||||
|
export * from './core';
|
||||||
|
export * from './react';
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import { render, fireEvent, screen } from '@testing-library/react';
|
|||||||
import type { AppRegistryMap, LayoutPanel, PanelComponents } from './types';
|
import type { AppRegistryMap, LayoutPanel, PanelComponents } from './types';
|
||||||
import type { WorkspaceContextValue } from './WorkspaceContext';
|
import type { WorkspaceContextValue } from './WorkspaceContext';
|
||||||
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
|
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
|
||||||
import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from './layout-utils';
|
import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from '../core/layout';
|
||||||
import { PanelSlot } from './PanelSlot';
|
import { PanelSlot } from './PanelSlot';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+4
-4
@@ -3,9 +3,9 @@ import { useCallback } from 'react';
|
|||||||
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react';
|
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
|
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
|
||||||
import { useWorkspace } from './WorkspaceContext';
|
import { useWorkspace } from './WorkspaceContext';
|
||||||
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils';
|
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from '../core/layout';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from './internal/Card';
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
import { ErrorBoundary } from './internal/ErrorBoundary';
|
||||||
import { AppPicker } from './AppPicker';
|
import { AppPicker } from './AppPicker';
|
||||||
import { PanelErrorFallback } from './PanelErrorFallback';
|
import { PanelErrorFallback } from './PanelErrorFallback';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
} from '@/components/ui/context-menu';
|
} from './internal/context-menu';
|
||||||
|
|
||||||
type PanelSlotProps = {
|
type PanelSlotProps = {
|
||||||
panel: LayoutPanel;
|
panel: LayoutPanel;
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
import type { DropPosition } from './layout-utils';
|
import type { DropPosition } from '../core/layout';
|
||||||
import type { PanelConfig } from './types';
|
import type { PanelConfig } from './types';
|
||||||
import type { WorkspaceIdentity } from './workspace-identity';
|
import type { WorkspaceIdentity } from '../core/identity';
|
||||||
|
|
||||||
export type WorkspaceContextValue = {
|
export type WorkspaceContextValue = {
|
||||||
/** Which dashboard or screen this panel is on. Null in a preview or a settings pane. */
|
/** Which dashboard or screen this panel is on. Null in a preview or a settings pane. */
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { LayoutNode, AppRegistryMap, PanelComponents } from './types';
|
||||||
|
import { updateSizes, collectPanelConfigs } from '../core/layout';
|
||||||
|
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
|
||||||
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
|
|
||||||
|
type WorkspaceLayoutProps = {
|
||||||
|
layout: LayoutNode;
|
||||||
|
onLayoutChange: (layout: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void;
|
||||||
|
/** `appType` -> component, from the host. See `WorkspaceView`. */
|
||||||
|
registry: AppRegistryMap;
|
||||||
|
components?: PanelComponents;
|
||||||
|
cwd?: string;
|
||||||
|
noHeader?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
// No mobile props. This renderer had `isMobile`/`mobilePanelId`/`onMobileBack` pass-throughs that none of
|
||||||
|
// its ten call sites ever set, so the mobile collapse they feed was permanently off here — and being
|
||||||
|
// optional, that read as a feature rather than the dead wiring it was. `WorkspaceView` is where mobile
|
||||||
|
// collapse actually happens, and it passes those to `WorkspaceRenderer` itself.
|
||||||
|
export const WorkspaceLayout = ({
|
||||||
|
layout,
|
||||||
|
onLayoutChange,
|
||||||
|
registry,
|
||||||
|
components,
|
||||||
|
cwd,
|
||||||
|
noHeader,
|
||||||
|
}: WorkspaceLayoutProps) => {
|
||||||
|
// An updater, for the same reason `WorkspaceView`'s eight mutations are: `WorkspaceRenderer` debounces
|
||||||
|
// this by 500 ms, so a tree computed here is 500 ms stale by the time it is written.
|
||||||
|
const handleResized = useCallback(
|
||||||
|
(groupId: string, sizes: number[]) => {
|
||||||
|
onLayoutChange((prev) => updateSizes(prev, groupId, sizes));
|
||||||
|
},
|
||||||
|
[onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// No workspace identity: this is the locked/preview renderer, used for job detail panes, settings and
|
||||||
|
// dashboard previews. It is not a dashboard and not a screen, and a panel that asks should be told so
|
||||||
|
// rather than handed something that parses.
|
||||||
|
return (
|
||||||
|
<WorkspaceProvider
|
||||||
|
value={{
|
||||||
|
workspace: null,
|
||||||
|
cwd: cwd ?? '~',
|
||||||
|
panelConfigs: collectPanelConfigs(layout),
|
||||||
|
...inertInteraction,
|
||||||
|
isMobile: false,
|
||||||
|
onMobileBack: null,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WorkspaceRenderer
|
||||||
|
layout={layout}
|
||||||
|
registry={registry}
|
||||||
|
components={components}
|
||||||
|
noHeader={noHeader}
|
||||||
|
onSetApp={noop}
|
||||||
|
onSplit={noop}
|
||||||
|
onRemove={noop}
|
||||||
|
onResized={handleResized}
|
||||||
|
/>
|
||||||
|
</WorkspaceProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { render, cleanup } from '@testing-library/react';
|
import { render, cleanup } from '@testing-library/react';
|
||||||
import type { AppRegistryMap, LayoutGroup, LayoutNode, LayoutPanel } from './types';
|
import type { AppRegistryMap, LayoutGroup, LayoutNode, LayoutPanel } from './types';
|
||||||
import { movePanel, removePanel, splitPanel, updateSizes } from './layout-utils';
|
import { movePanel, removePanel, splitPanel, updateSizes } from '../core/layout';
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
|
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
|
||||||
|
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from './internal/resizable';
|
||||||
import type { LayoutNode, AppRegistryMap, PanelComponents } from './types';
|
import type { LayoutNode, AppRegistryMap, PanelComponents } from './types';
|
||||||
import { countPanels } from './layout-utils';
|
import { countPanels } from '../core/layout';
|
||||||
import { PanelSlot } from './PanelSlot';
|
import { PanelSlot } from './PanelSlot';
|
||||||
|
|
||||||
type WorkspaceRendererProps = {
|
type WorkspaceRendererProps = {
|
||||||
+4
-13
@@ -1,9 +1,7 @@
|
|||||||
import { describe, expect, test, beforeEach } from 'bun:test';
|
import { describe, expect, test, beforeEach } from 'bun:test';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { render, fireEvent, screen } from '@testing-library/react';
|
import { render, fireEvent, screen } from '@testing-library/react';
|
||||||
import { globalQueryKey } from 'hooks/useGlobal';
|
|
||||||
import type { AppRegistryMap, DashboardState, LayoutGroup, LayoutNode, LayoutPanel } from './types';
|
import type { AppRegistryMap, DashboardState, LayoutGroup, LayoutNode, LayoutPanel } from './types';
|
||||||
import { countPanels, findPanelApp } from './layout-utils';
|
import { countPanels, findPanelApp } from '../core/layout';
|
||||||
import { WorkspaceView } from './WorkspaceView';
|
import { WorkspaceView } from './WorkspaceView';
|
||||||
import { usePanelClose } from './usePanelClose';
|
import { usePanelClose } from './usePanelClose';
|
||||||
|
|
||||||
@@ -58,7 +56,6 @@ const registry: AppRegistryMap = {
|
|||||||
other: { name: 'Other', icon: ProbeIcon, component: Probe },
|
other: { name: 'Other', icon: ProbeIcon, component: Probe },
|
||||||
};
|
};
|
||||||
|
|
||||||
let queryClient: QueryClient;
|
|
||||||
let writes: Recorded[];
|
let writes: Recorded[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,11 +83,9 @@ function mount(initial: LayoutNode, props: Partial<Parameters<typeof WorkspaceVi
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tree = () => (
|
// No provider of any kind. The registry is a prop now, so this package renders under a bare React tree —
|
||||||
<QueryClientProvider client={queryClient}>
|
// which is also the proof that a host needs nothing from us but a registry and a layout.
|
||||||
<WorkspaceView {...props} workspace={workspace()} />
|
const tree = () => <WorkspaceView registry={registry} {...props} workspace={workspace()} />;
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
view = render(tree());
|
view = render(tree());
|
||||||
return { layout: () => current, rerender: () => view?.rerender(tree()) };
|
return { layout: () => current, rerender: () => view?.rerender(tree()) };
|
||||||
@@ -99,10 +94,6 @@ function mount(initial: LayoutNode, props: Partial<Parameters<typeof WorkspaceVi
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
closeBehaviour.clear();
|
closeBehaviour.clear();
|
||||||
closesFired.length = 0;
|
closesFired.length = 0;
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
// `useAppRegistry` reads a `useGlobal` key that `seedAppRegistry` writes before anything renders. Seeding
|
|
||||||
// the cache directly is the same thing without dragging in the real app list.
|
|
||||||
queryClient.setQueryData(globalQueryKey('APP_REGISTRY'), registry);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('every write is an updater, not a computed tree', () => {
|
describe('every write is an updater, not a computed tree', () => {
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from './internal/resizable';
|
||||||
|
import { useIsMobile } from './internal/useIsMobile';
|
||||||
|
import type {
|
||||||
|
LayoutNode,
|
||||||
|
DashboardState,
|
||||||
|
EphemeralPanels,
|
||||||
|
PanelComponents,
|
||||||
|
PanelConfig,
|
||||||
|
AppRegistryMap,
|
||||||
|
} from './types';
|
||||||
|
import type { DropPosition } from '../core/layout';
|
||||||
|
import {
|
||||||
|
splitPanel,
|
||||||
|
removePanel,
|
||||||
|
setApp,
|
||||||
|
updateSizes,
|
||||||
|
swapPanels,
|
||||||
|
movePanel,
|
||||||
|
countPanels,
|
||||||
|
setZoom,
|
||||||
|
setPanelConfig,
|
||||||
|
collectPanelConfigs,
|
||||||
|
findPanelApp,
|
||||||
|
normalizeLayout,
|
||||||
|
} from '../core/layout';
|
||||||
|
import type { WorkspaceContextValue } from './WorkspaceContext';
|
||||||
|
import { WorkspaceProvider } from './WorkspaceContext';
|
||||||
|
import { parseWorkspaceKey } from '../core/identity';
|
||||||
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
|
|
||||||
|
export type WorkspaceViewProps = {
|
||||||
|
workspace: DashboardState;
|
||||||
|
locked?: boolean;
|
||||||
|
/**
|
||||||
|
* Pin every panel to one of these app types, replacing anything else with `fallback` and writing the
|
||||||
|
* repair back. A locked screen should always pass this: its layout is persisted user state that
|
||||||
|
* outlives the code, and an appType the screen no longer renders becomes an empty box the user cannot
|
||||||
|
* get out of, because a locked screen has no app picker.
|
||||||
|
*
|
||||||
|
* This used to be fourteen character-identical `normalizeLayout` functions in fourteen screens, each
|
||||||
|
* with its own `useMemo` and persist-back `useEffect` — which is how nine screens ended up with no
|
||||||
|
* guard at all and one with a guard that re-ran forever because it never wrote the correction back.
|
||||||
|
*/
|
||||||
|
appTypes?: { allowed: readonly string[]; fallback: string };
|
||||||
|
cwd?: string;
|
||||||
|
root?: string;
|
||||||
|
components?: PanelComponents;
|
||||||
|
ephemeral?: EphemeralPanels | null;
|
||||||
|
mobilePanelId?: string;
|
||||||
|
onMobilePanelChange?: (id: string | null) => void;
|
||||||
|
/**
|
||||||
|
* `appType` -> component. Supplied by the host: this package never imports a panel and has no idea what
|
||||||
|
* apps exist. Officer passes its `AppRegistry`; another host passes whatever it likes.
|
||||||
|
*/
|
||||||
|
registry: AppRegistryMap;
|
||||||
|
/**
|
||||||
|
* Which panel is fullscreen, when the host wants to own it — the controlled half of a
|
||||||
|
* controlled/uncontrolled pair. Left out, the workspace keeps it in local state.
|
||||||
|
*
|
||||||
|
* Offered because fullscreen IS layout state: whether it survives a reload, and where it is kept, is the
|
||||||
|
* host's decision. Officer puts it in sessionStorage per tab, which is a fact about Officer.
|
||||||
|
*/
|
||||||
|
fullscreenPanelId?: string | null;
|
||||||
|
onFullscreenPanelChange?: (id: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
export const WorkspaceView = ({
|
||||||
|
workspace,
|
||||||
|
locked,
|
||||||
|
appTypes,
|
||||||
|
cwd = '~',
|
||||||
|
root,
|
||||||
|
components,
|
||||||
|
ephemeral,
|
||||||
|
mobilePanelId,
|
||||||
|
onMobilePanelChange,
|
||||||
|
registry,
|
||||||
|
fullscreenPanelId: controlledFullscreen,
|
||||||
|
onFullscreenPanelChange,
|
||||||
|
}: WorkspaceViewProps) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
// Deliberately not memoised. `normalizeLayout` returns its input by reference when nothing needed
|
||||||
|
// fixing, so the identity everything downstream depends on is already stable; a `useMemo` here would
|
||||||
|
// only add a dependency array to get wrong, and the callers pass a fresh object literal for `appTypes`
|
||||||
|
// anyway, which would defeat it. The walk is a handful of nodes.
|
||||||
|
const layout = appTypes ? normalizeLayout(workspace.value, appTypes.allowed, appTypes.fallback) : workspace.value;
|
||||||
|
const onLayoutChange = workspace.setValue;
|
||||||
|
|
||||||
|
// Write the repair back, so a layout is fixed once rather than re-fixed on every mount for the rest of
|
||||||
|
// its life. Terminates because the normalised tree normalises to itself.
|
||||||
|
const rawLayout = workspace.value;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspace.isLoaded || layout === rawLayout) return;
|
||||||
|
onLayoutChange(layout);
|
||||||
|
}, [workspace.isLoaded, layout, rawLayout, onLayoutChange]);
|
||||||
|
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
||||||
|
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
||||||
|
// Per tab and per dashboard: putting the chat panel full screen and refreshing should come back full
|
||||||
|
// screen, while a second window on the same screen keeps its own idea of what is blown up. A stored id
|
||||||
|
// whose panel has since been removed simply fills nothing — this is a style toggle on the panel itself,
|
||||||
|
// so there is nothing to strand.
|
||||||
|
//
|
||||||
|
/**
|
||||||
|
* Controlled when the host passes it, local otherwise.
|
||||||
|
*
|
||||||
|
* Officer keeps it in sessionStorage under `FULLSCREEN_PANEL:<workspace key>`, per tab — a decision
|
||||||
|
* about Officer, not about workspaces, which is exactly why it moved out of here. A host that does not
|
||||||
|
* care gets working fullscreen that dies with the component, and never has to know the key existed.
|
||||||
|
*/
|
||||||
|
const [uncontrolledFullscreen, setUncontrolledFullscreen] = useState<string | null>(null);
|
||||||
|
const fullscreenPanelId = controlledFullscreen !== undefined ? controlledFullscreen : uncontrolledFullscreen;
|
||||||
|
const setFullscreenPanelId = useCallback(
|
||||||
|
(id: string | null) => {
|
||||||
|
if (onFullscreenPanelChange) onFullscreenPanelChange(id);
|
||||||
|
if (controlledFullscreen === undefined) setUncontrolledFullscreen(id);
|
||||||
|
},
|
||||||
|
[onFullscreenPanelChange, controlledFullscreen],
|
||||||
|
);
|
||||||
|
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// A host may need to stand its own chrome down — Officer hides the nav header, because a panel cannot
|
||||||
|
// paint over it from inside the content region's stacking context. Reported, not performed: this package
|
||||||
|
// has no opinion about what else is on the page.
|
||||||
|
useEffect(() => {
|
||||||
|
onFullscreenPanelChange?.(fullscreenPanelId);
|
||||||
|
// Only when the VALUE changes; `onFullscreenPanelChange` is often an inline arrow.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fullscreenPanelId]);
|
||||||
|
|
||||||
|
const setFullscreenAnimated = useCallback(
|
||||||
|
(id: string | null) => {
|
||||||
|
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
|
||||||
|
const panelId = fullscreenPanelId ?? id;
|
||||||
|
if (doc.startViewTransition && panelId) {
|
||||||
|
setTransitioningPanelId(panelId);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const transition = doc.startViewTransition(() => flushSync(() => setFullscreenPanelId(id)));
|
||||||
|
transition.finished.finally(() => setTransitioningPanelId(null));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFullscreenPanelId(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fullscreenPanelId, setFullscreenPanelId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// What each panel wants done when it is closed, by panel id, stamped with the workspace it was
|
||||||
|
// registered on. A ref rather than state: nothing renders from it, and a panel registering during its
|
||||||
|
// own mount effect must not schedule a render of the whole workspace.
|
||||||
|
//
|
||||||
|
// Nothing is ever unregistered. A handler is dropped when it fires and replaced when the same panel id
|
||||||
|
// registers again; a panel that merely unmounted keeps its handler, because "unmounted" is precisely
|
||||||
|
// the signal this exists to avoid trusting. The stamp is what keeps that from leaking across
|
||||||
|
// dashboards — the same panel id can exist on two of them, and a handler from the one you navigated
|
||||||
|
// away from must not fire for the one you are on.
|
||||||
|
const closeHandlers = useRef(new Map<string, { workspaceKey: string; run: () => void }>());
|
||||||
|
const workspaceKeyRef = useRef(workspace.key);
|
||||||
|
workspaceKeyRef.current = workspace.key;
|
||||||
|
|
||||||
|
const registerPanelClose = useCallback((panelId: string, run: () => void) => {
|
||||||
|
closeHandlers.current.set(panelId, { workspaceKey: workspaceKeyRef.current, run });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const firePanelClose = useCallback((panelId: string) => {
|
||||||
|
const entry = closeHandlers.current.get(panelId);
|
||||||
|
if (!entry) return;
|
||||||
|
closeHandlers.current.delete(panelId);
|
||||||
|
if (entry.workspaceKey !== workspaceKeyRef.current) return;
|
||||||
|
// A handler that throws must not stop the panel from closing — the layout write is the user's
|
||||||
|
// instruction, and the cleanup is a best effort on top of it.
|
||||||
|
try {
|
||||||
|
entry.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`panel close handler for ${panelId} threw`, err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Every mutation goes through `onLayoutChange` as an UPDATER, never as a computed tree.
|
||||||
|
//
|
||||||
|
// Each of these used to close over `layout` as it was when the callback was made, and two of the paths
|
||||||
|
// are not immediate: the resize debounce fires 500 ms after the drag began, and a window resize fires
|
||||||
|
// `onLayout` on every group at once. So the second write was computed from a tree that predated the
|
||||||
|
// first and silently undid it — remove a panel just after dragging a splitter and it came back. That is
|
||||||
|
// worse than it sounds now that panel identity lives in the layout: the resurrected tree carries an
|
||||||
|
// older `config`, so a panel that was just given an agent's name reverts to anonymous, and the agent it
|
||||||
|
// addresses is no longer reachable through it.
|
||||||
|
//
|
||||||
|
// The guards below still test the rendered `layout` — they only decide whether a write is worth making,
|
||||||
|
// and being one render stale there costs a redundant no-op write at worst.
|
||||||
|
//
|
||||||
|
// `handleSetApp` and `handleRemove` additionally fire the closing panel's `usePanelClose` handler.
|
||||||
|
// They are the only two places that do, and they are the only two that know a close was *asked for* —
|
||||||
|
// a drag, a swap and a mobile panel switch all tear a panel's component down without closing anything.
|
||||||
|
const handleSetApp = useCallback(
|
||||||
|
(panelId: string, appType: string | null) => {
|
||||||
|
// Re-picking the app that is already there is not a close: `setApp` keeps the config and the app
|
||||||
|
// never goes away. Every other transition retires whatever was running.
|
||||||
|
if (findPanelApp(layout, panelId) !== appType) firePanelClose(panelId);
|
||||||
|
onLayoutChange((prev) => setApp(prev, panelId, appType));
|
||||||
|
},
|
||||||
|
[layout, onLayoutChange, firePanelClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSplit = useCallback(
|
||||||
|
(panelId: string, direction: 'horizontal' | 'vertical') => {
|
||||||
|
onLayoutChange((prev) => splitPanel(prev, panelId, direction));
|
||||||
|
},
|
||||||
|
[onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(panelId: string) => {
|
||||||
|
if (countPanels(layout) <= 1) return;
|
||||||
|
// Fired before the write, from the rendered tree, rather than from inside the updater — an updater
|
||||||
|
// has to stay pure and may run twice. The updater's own guard can therefore decline a removal we
|
||||||
|
// already announced, if a concurrent write shrank the tree to one panel in between. That costs a
|
||||||
|
// cleanup for a panel still on screen; it needs two writes to race inside one debounce window, and
|
||||||
|
// the alternative is a side effect in an updater.
|
||||||
|
firePanelClose(panelId);
|
||||||
|
onLayoutChange((prev) => (countPanels(prev) <= 1 ? prev : removePanel(prev, panelId)));
|
||||||
|
},
|
||||||
|
[layout, onLayoutChange, firePanelClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResized = useCallback(
|
||||||
|
(groupId: string, sizes: number[]) => {
|
||||||
|
onLayoutChange((prev) => updateSizes(prev, groupId, sizes));
|
||||||
|
},
|
||||||
|
[onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetZoom = useCallback(
|
||||||
|
(panelId: string, zoom: number) => {
|
||||||
|
if (setZoom(layout, panelId, zoom) === layout) return;
|
||||||
|
onLayoutChange((prev) => setZoom(prev, panelId, zoom));
|
||||||
|
},
|
||||||
|
[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]);
|
||||||
|
|
||||||
|
// Memoised on the key, not recomputed inline: the provider value is a fresh object every render
|
||||||
|
// anyway, but this one ends up in consumers' dependency arrays (`useAgentPanel`'s query key, the
|
||||||
|
// terminal state key). A new identity per render is how `useClient()` silently disabled every
|
||||||
|
// playback report in the Jellyfin player — same shape, so don't reproduce it here.
|
||||||
|
const identity = useMemo(() => parseWorkspaceKey(workspace.key), [workspace.key]);
|
||||||
|
|
||||||
|
const handleSetPanelConfig = useCallback(
|
||||||
|
(panelId: string, config: PanelConfig | undefined) => {
|
||||||
|
if (setPanelConfig(layout, panelId, config) === layout) return;
|
||||||
|
onLayoutChange((prev) => setPanelConfig(prev, panelId, config));
|
||||||
|
},
|
||||||
|
[layout, onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSwap = useCallback(
|
||||||
|
(sourceId: string, targetId: string) => {
|
||||||
|
onLayoutChange((prev) => swapPanels(prev, sourceId, targetId));
|
||||||
|
setSwapSourceId(null);
|
||||||
|
},
|
||||||
|
[onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMove = useCallback(
|
||||||
|
(sourceId: string, targetId: string, position: DropPosition) => {
|
||||||
|
onLayoutChange((prev) => movePanel(prev, sourceId, targetId, position));
|
||||||
|
setDragSourceId(null);
|
||||||
|
},
|
||||||
|
[onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startDrag = useCallback((id: string | null) => {
|
||||||
|
setSwapSourceId(null);
|
||||||
|
setDragSourceId(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Escape cancels a *pending* swap or drag — modes you are halfway through, where it is the only way
|
||||||
|
// out. Maximize is not one of those: it is a state you chose and that now outlives a refresh, so a key
|
||||||
|
// press meant for whatever you are typing in should not throw it away. Restore is the panel's own
|
||||||
|
// button.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!swapSourceId && !dragSourceId) return;
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
setSwapSourceId(null);
|
||||||
|
setDragSourceId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [swapSourceId, dragSourceId]);
|
||||||
|
|
||||||
|
const ephemeralPanelRef = useRef<ComponentRef<typeof ResizablePanel>>(null);
|
||||||
|
const isEphemeralOpen = !!ephemeral;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const panel = ephemeralPanelRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
// `react-resizable-panels` asserts rather than no-ops when it is asked to resize a group it has not
|
||||||
|
// laid out yet — and an assert thrown from an effect aborts the commit, which here means the whole
|
||||||
|
// dashboard rather than the pane that could not be sized. The pane is a file preview; the panels
|
||||||
|
// behind it are a night's work. Only the ephemeral pane's own geometry is at stake, and the next
|
||||||
|
// render tries again.
|
||||||
|
try {
|
||||||
|
if (isEphemeralOpen) {
|
||||||
|
panel.resize(100 - (ephemeral.defaultBaseSize ?? 40));
|
||||||
|
} else {
|
||||||
|
panel.collapse();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[workspace] could not size the ephemeral pane yet', err);
|
||||||
|
}
|
||||||
|
}, [isEphemeralOpen, ephemeral?.defaultBaseSize]);
|
||||||
|
|
||||||
|
const onMobileBack = useMemo(() => {
|
||||||
|
if (!isMobile) return null;
|
||||||
|
if (mobilePanelId && onMobilePanelChange) return () => onMobilePanelChange(null);
|
||||||
|
if (isEphemeralOpen && ephemeral?.onClose) return ephemeral.onClose;
|
||||||
|
return null;
|
||||||
|
}, [isMobile, mobilePanelId, onMobilePanelChange, isEphemeralOpen, ephemeral?.onClose]);
|
||||||
|
|
||||||
|
// A fresh object literal here re-rendered every `useWorkspace()` consumer in every panel on every
|
||||||
|
// `WorkspaceView` render — including each frame of a maximize animation, and including renders that
|
||||||
|
// changed nothing a panel can see (the ephemeral pane opening, a mobile panel switch). Memoised, a panel
|
||||||
|
// only re-renders when something it is actually given changes.
|
||||||
|
const contextValue = useMemo<WorkspaceContextValue>(
|
||||||
|
() => ({
|
||||||
|
workspace: identity,
|
||||||
|
cwd,
|
||||||
|
root,
|
||||||
|
panelConfigs,
|
||||||
|
setPanelConfig: locked ? noop : handleSetPanelConfig,
|
||||||
|
registerPanelClose,
|
||||||
|
swapSourceId,
|
||||||
|
setSwapSourceId,
|
||||||
|
onSwap: handleSwap,
|
||||||
|
dragSourceId,
|
||||||
|
setDragSourceId: startDrag,
|
||||||
|
onMove: handleMove,
|
||||||
|
onSetZoom: handleSetZoom,
|
||||||
|
fullscreenPanelId,
|
||||||
|
setFullscreenPanelId: setFullscreenAnimated,
|
||||||
|
transitioningPanelId,
|
||||||
|
isMobile,
|
||||||
|
onMobileBack,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
identity,
|
||||||
|
cwd,
|
||||||
|
root,
|
||||||
|
panelConfigs,
|
||||||
|
locked,
|
||||||
|
handleSetPanelConfig,
|
||||||
|
registerPanelClose,
|
||||||
|
swapSourceId,
|
||||||
|
handleSwap,
|
||||||
|
dragSourceId,
|
||||||
|
startDrag,
|
||||||
|
handleMove,
|
||||||
|
handleSetZoom,
|
||||||
|
fullscreenPanelId,
|
||||||
|
setFullscreenAnimated,
|
||||||
|
transitioningPanelId,
|
||||||
|
isMobile,
|
||||||
|
onMobileBack,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace.isLoaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceProvider value={contextValue}>
|
||||||
|
{isMobile && isEphemeralOpen && ephemeral ? (
|
||||||
|
<WorkspaceRenderer
|
||||||
|
layout={ephemeral.layout}
|
||||||
|
registry={registry}
|
||||||
|
components={ephemeral.components}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onSetApp={noop}
|
||||||
|
onSplit={noop}
|
||||||
|
onRemove={noop}
|
||||||
|
onResized={noop}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||||
|
<ResizablePanel defaultSize={100} minSize={15}>
|
||||||
|
<WorkspaceRenderer
|
||||||
|
layout={layout}
|
||||||
|
registry={registry}
|
||||||
|
components={components}
|
||||||
|
interactive
|
||||||
|
locked={locked}
|
||||||
|
isMobile={isMobile}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
|
onSetApp={locked ? noop : handleSetApp}
|
||||||
|
onSplit={locked ? noop : handleSplit}
|
||||||
|
onRemove={locked ? noop : handleRemove}
|
||||||
|
onResized={handleResized}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
|
||||||
|
<ResizablePanel ref={ephemeralPanelRef} collapsible collapsedSize={0} defaultSize={0} minSize={15}>
|
||||||
|
{isEphemeralOpen ? (
|
||||||
|
<WorkspaceRenderer
|
||||||
|
layout={ephemeral.layout}
|
||||||
|
registry={registry}
|
||||||
|
components={ephemeral.components}
|
||||||
|
onSetApp={noop}
|
||||||
|
onSplit={noop}
|
||||||
|
onRemove={noop}
|
||||||
|
onResized={noop}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
)}
|
||||||
|
</WorkspaceProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* The React renderer for a workspace.
|
||||||
|
*
|
||||||
|
* Everything here is a component or a hook; the tree and its operations are in `workspaces/core` and have
|
||||||
|
* no idea React exists. That split is deliberate — a second renderer reuses the core untouched.
|
||||||
|
*
|
||||||
|
* The host supplies two things and owns both: a `registry` mapping `appType` to a component, and the
|
||||||
|
* layout with a way to change it. This package performs no IO and imports no panel.
|
||||||
|
*/
|
||||||
|
export { WorkspaceView } from './WorkspaceView';
|
||||||
|
export type { WorkspaceViewProps } from './WorkspaceView';
|
||||||
|
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||||
|
export { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
|
export { PanelSlot } from './PanelSlot';
|
||||||
|
export { WorkspaceProvider, useWorkspace, inertInteraction } from './WorkspaceContext';
|
||||||
|
export type { WorkspaceContextValue } from './WorkspaceContext';
|
||||||
|
export { usePanelConfig } from './usePanelConfig';
|
||||||
|
export { usePanelClose } from './usePanelClose';
|
||||||
|
export type {
|
||||||
|
DashboardState,
|
||||||
|
AppRegistryEntry,
|
||||||
|
AppRegistryMap,
|
||||||
|
PanelComponents,
|
||||||
|
PanelComponentEntry,
|
||||||
|
EphemeralPanels,
|
||||||
|
} from './types';
|
||||||
|
export type { LayoutNode, LayoutGroup, LayoutPanel, PanelConfig } from '../core/types';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { CSSProperties, ComponentPropsWithRef } from 'react';
|
||||||
|
import { cn } from './cn';
|
||||||
|
|
||||||
|
export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({
|
||||||
|
backgroundColor: 'var(--card-bg)',
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(to right, var(--card-grid-color) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, var(--card-grid-color) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: '20px 20px',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
type CardProps = ComponentPropsWithRef<'div'>;
|
||||||
|
|
||||||
|
export const Card = ({ className, style, ...props }: CardProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('rounded-xl border-2 border-duck-dark/30 shadow-lg p-5', className)}
|
||||||
|
style={cardStyle(style)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Component } from 'react';
|
||||||
|
import type { ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type FallbackArgs = { error: Error; reset: () => void };
|
||||||
|
|
||||||
|
type ErrorBoundaryProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Rendered in place of the children once something below has thrown. */
|
||||||
|
fallback: (args: FallbackArgs) => ReactNode;
|
||||||
|
/**
|
||||||
|
* Clear the error whenever any of these changes — pass the pathname to recover on navigation, or the
|
||||||
|
* id of the thing being rendered. Without it a boundary latches: the screen stays broken until reload
|
||||||
|
* even after the user has navigated somewhere that would render fine.
|
||||||
|
*/
|
||||||
|
resetKeys?: readonly unknown[];
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorBoundaryState = { error: Error | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repo had none of these. A single malformed value anywhere below the router took the whole app to a
|
||||||
|
* white screen, and the only recovery from a bad stored layout was SQL — which is the wrong thing to ask
|
||||||
|
* of someone who has just opened the dashboard to read what happened overnight.
|
||||||
|
*
|
||||||
|
* Deliberately a render-prop rather than a fixed panel: what "recover" means is different for a screen
|
||||||
|
* (go somewhere else) and for one panel inside a workspace (clear the app, keep the rest running).
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
override state: ErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
console.error('[error-boundary]', error, info.componentStack);
|
||||||
|
this.props.onError?.(error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidUpdate(prev: ErrorBoundaryProps): void {
|
||||||
|
if (!this.state.error) return;
|
||||||
|
const a = prev.resetKeys ?? [];
|
||||||
|
const b = this.props.resetKeys ?? [];
|
||||||
|
if (a.length !== b.length || a.some((key, i) => !Object.is(key, b[i]))) this.setState({ error: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = (): void => this.setState({ error: null });
|
||||||
|
|
||||||
|
override render(): ReactNode {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (error) return this.props.fallback({ error, reset: this.reset });
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/** Merge class names, last Tailwind utility winning. Vendored so this package needs no host aliases. */
|
||||||
|
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from './cn';
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||||
|
));
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />;
|
||||||
|
};
|
||||||
|
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||||
|
|
||||||
|
import { cn } from './cn';
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel;
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean>(
|
||||||
|
typeof window !== 'undefined' ? window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches : false,
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
|
||||||
|
const onChange = (event: MediaQueryListEvent) => {
|
||||||
|
setIsMobile(event.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsMobile(mql.matches);
|
||||||
|
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseIsMobileReturn = ReturnType<typeof useIsMobile>;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
|
import type { LayoutNode, PanelConfig } from '../core/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The React-facing half of the model. The tree itself lives in `../core/types` and knows nothing about
|
||||||
|
* components — that split is what phase 2 inherits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { LayoutNode, LayoutGroup, LayoutPanel, PanelConfig } from '../core/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout, and how to change it. The host owns storage entirely: this package reads `value` and calls
|
||||||
|
* `setValue`, and has no idea whether that reaches a server, localStorage or nothing at all.
|
||||||
|
*/
|
||||||
|
export type DashboardState = {
|
||||||
|
key: string;
|
||||||
|
value: LayoutNode;
|
||||||
|
setValue: (value: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void;
|
||||||
|
isLoaded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One app the host offers. `icon` is a component rather than a `LucideIcon` so the package does not force
|
||||||
|
* an icon library on anyone — Officer's lucide icons satisfy it without changing.
|
||||||
|
*/
|
||||||
|
export type AppRegistryEntry = {
|
||||||
|
name: string;
|
||||||
|
icon: ComponentType<{ className?: string; size?: number | string }>;
|
||||||
|
component: ComponentType<{ panelId: string }>;
|
||||||
|
header?: ComponentType<{ panelId: string }>;
|
||||||
|
provider?: ComponentType<{ panelId: string; children: ReactNode }>;
|
||||||
|
transparent?: boolean;
|
||||||
|
availableOnPanel?: boolean;
|
||||||
|
/** Opt out of the zoom control — a terminal or a canvas that scales badly under a CSS transform. */
|
||||||
|
zoomable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppRegistryMap = Record<string, AppRegistryEntry>;
|
||||||
|
|
||||||
|
/** A panel supplied by a screen rather than by the registry. Rendered with `panelId`, exactly as those are. */
|
||||||
|
export type PanelComponentEntry = {
|
||||||
|
component: ComponentType<{ panelId: string }>;
|
||||||
|
header?: ComponentType<{ panelId: string }>;
|
||||||
|
provider?: ComponentType<{ panelId: string; children: ReactNode }>;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PanelComponents = Record<string, ComponentType<{ panelId: string }> | PanelComponentEntry>;
|
||||||
|
|
||||||
|
export type EphemeralPanels = {
|
||||||
|
layout: LayoutNode;
|
||||||
|
components: PanelComponents;
|
||||||
|
defaultBaseSize?: number;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user