This commit is contained in:
2026-02-19 12:03:45 +00:00
parent dd8ab84df5
commit 111d4c86eb
12 changed files with 174 additions and 183 deletions
@@ -6,7 +6,7 @@ type AppPickerProps = {
};
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
const entries = Object.entries(registry);
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget);
return (
<div className="grid grid-cols-3 gap-2 max-w-xs">
@@ -1,88 +1,92 @@
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
import { Card } from '../Card';
import { AppPicker } from './AppPicker';
import { LayoutEditor } from './LayoutEditor';
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
} from '../ui/context-menu';
type PanelSlotProps = {
panel: LayoutPanel;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
interactive: boolean;
isLastPanel: boolean;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
};
export const PanelSlot = ({ panel, registry, components, editing, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
type PanelContextMenuProps = {
panelId: string;
hasApp: boolean;
isLastPanel: boolean;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
onClearApp: () => void;
children: React.ReactNode;
};
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
{!isLastPanel && (
<ContextMenuItem className="text-red-500 focus:text-red-500" onClick={() => onRemove(panelId)}>
Remove panel
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
const PanelComponent = components?.[panel.id];
const entry = panel.appType ? registry[panel.appType] : null;
const AppComponent = PanelComponent ?? entry?.component;
if (!editing && !AppComponent) {
return (
<div className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
</div>
);
}
const contextMenu = interactive
? (content: React.ReactNode) => (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
{content}
</PanelContextMenu>
)
: (content: React.ReactNode) => <>{content}</>;
if (!AppComponent) {
return (
if (!interactive) {
return (
<div className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
</div>
);
}
return contextMenu(
<div className="h-full w-full p-1">
<Card className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4">
<AppPicker registry={registry} onSelect={(type) => onSetApp(panel.id, type)} />
<div className="flex gap-1">
<button
type="button"
className="px-2 py-1 text-xs rounded border border-duck-teal/20 text-duck-teal/70 hover:bg-duck-teal/10 hover:text-duck-teal cursor-pointer"
onClick={() => onSplit(panel.id, 'horizontal')}
>
Split H
</button>
<button
type="button"
className="px-2 py-1 text-xs rounded border border-duck-teal/20 text-duck-teal/70 hover:bg-duck-teal/10 hover:text-duck-teal cursor-pointer"
onClick={() => onSplit(panel.id, 'vertical')}
>
Split V
</button>
{!isLastPanel && (
<button
type="button"
className="px-2 py-1 text-xs rounded border border-red-400/40 text-red-400/70 hover:bg-red-400/10 hover:text-red-400 cursor-pointer"
onClick={() => onRemove(panel.id)}
>
Remove
</button>
)}
</div>
</Card>
</div>
</div>,
);
}
if (entry?.transparent) {
return (
return contextMenu(
<div className="h-full w-full p-1">
<div className="relative h-full w-full overflow-hidden">
<AppComponent panelId={panel.id} />
{editing && (
<LayoutEditor
panelId={panel.id}
hasWidget
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)}
</div>
</div>
</div>,
);
}
return (
return contextMenu(
<div className="h-full w-full p-1">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
@@ -91,17 +95,7 @@ export const PanelSlot = ({ panel, registry, components, editing, isLastPanel, o
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-auto">
<AppComponent panelId={panel.id} />
</Card>
{editing && (
<LayoutEditor
panelId={panel.id}
hasWidget
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)}
</div>
</div>
</div>,
);
};
@@ -3,13 +3,11 @@ import { createContext, useContext } from 'react';
type WorkspaceContextValue = {
workspaceId: string | null;
cwd: string;
editing: boolean;
};
const WorkspaceContext = createContext<WorkspaceContextValue>({
workspaceId: null,
cwd: '~',
editing: false,
});
export const WorkspaceProvider = WorkspaceContext.Provider;
@@ -24,12 +24,11 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
);
return (
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', editing: false }}>
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~' }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
components={components}
editing={false}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
@@ -8,7 +8,7 @@ type WorkspaceRendererProps = {
layout: LayoutNode;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
interactive?: boolean;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
@@ -19,7 +19,7 @@ export const WorkspaceRenderer = ({
layout,
registry,
components,
editing,
interactive = false,
onSetApp,
onSplit,
onRemove,
@@ -33,7 +33,7 @@ export const WorkspaceRenderer = ({
node={layout}
registry={registry}
components={components}
editing={editing}
interactive={interactive}
totalPanels={totalPanels}
onSetApp={onSetApp}
onSplit={onSplit}
@@ -48,7 +48,7 @@ type LayoutNodeRendererProps = {
node: LayoutNode;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
interactive: boolean;
totalPanels: number;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
@@ -65,7 +65,7 @@ const LayoutNodeRenderer = ({
node,
registry,
components,
editing,
interactive,
totalPanels,
onSetApp,
onSplit,
@@ -96,7 +96,7 @@ const LayoutNodeRenderer = ({
panel={node}
registry={registry}
components={components}
editing={editing}
interactive={interactive}
isLastPanel={totalPanels <= 1}
onSetApp={onSetApp}
onSplit={onSplit}
@@ -118,7 +118,7 @@ const LayoutNodeRenderer = ({
node={child.node}
registry={registry}
components={components}
editing={editing}
interactive={interactive}
totalPanels={totalPanels}
onSetApp={onSetApp}
onSplit={onSplit}
@@ -133,16 +133,16 @@ const LayoutNodeRenderer = ({
}
return (
<ResizablePanelGroup direction={node.direction} onLayout={handleLayout} className="h-full w-full">
<ResizablePanelGroup id={node.id} direction={node.direction} onLayout={handleLayout} className="h-full w-full">
{node.children.map((child, i) => (
<ChildEntry key={child.node.id} index={i} total={node.children.length}>
<ResizablePanel defaultSize={child.size} minSize={5}>
<ResizablePanel id={child.node.id} order={i} defaultSize={child.size} minSize={5}>
<div className="h-full w-full">
<LayoutNodeRenderer
node={child.node}
registry={registry}
components={components}
editing={editing}
interactive={interactive}
totalPanels={totalPanels}
onSetApp={onSetApp}
onSplit={onSplit}
@@ -1,21 +1,17 @@
import { useState, useCallback } from 'react';
import { useCallback } from 'react';
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
import { splitPanel, removePanel, setApp, updateSizes, countPanels, hasAnyApp } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, countPanels } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceHeader } from './WorkspaceHeader';
import { WorkspaceRenderer } from './WorkspaceRenderer';
type WorkspaceViewProps = {
workspace: WorkspaceDefinition | null;
name?: string;
layout: LayoutNode;
onLayoutChange: (layout: LayoutNode) => void;
registry: AppRegistry;
};
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
const [editing, setEditing] = useState(() => !hasAnyApp(layout));
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
const handleSetApp = useCallback(
(panelId: string, appType: string | null) => {
onLayoutChange(setApp(layout, panelId, appType));
@@ -45,24 +41,17 @@ export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registr
[layout, onLayoutChange],
);
const displayName = name ?? workspace?.name ?? 'Workspace';
return (
<WorkspaceProvider value={{ workspaceId: workspace?.id ?? null, cwd: workspace?.cwd ?? '~', editing }}>
<div className="flex h-full w-full flex-col">
<WorkspaceHeader name={displayName} editing={editing} onToggleEdit={() => setEditing((v) => !v)} />
<div className="flex-1 overflow-hidden">
<WorkspaceRenderer
layout={layout}
registry={registry}
editing={editing}
onSetApp={handleSetApp}
onSplit={handleSplit}
onRemove={handleRemove}
onResized={handleResized}
/>
</div>
</div>
<WorkspaceProvider value={{ workspaceId: workspace?.id ?? null, cwd: workspace?.cwd ?? '~' }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
interactive
onSetApp={handleSetApp}
onSplit={handleSplit}
onRemove={handleRemove}
onResized={handleResized}
/>
</WorkspaceProvider>
);
};
@@ -10,15 +10,14 @@ export const createDefaultLayout = (): LayoutPanel => ({
});
export function splitPanel(root: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
return mapNode(root, (node, parent) => {
if (node.type !== 'panel' || node.id !== panelId) return node;
return splitInner(root, panelId, direction);
}
function splitInner(node: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
if (node.type === 'panel') {
if (node.id !== panelId) return node;
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
if (parent && parent.direction === direction) {
return null;
}
const group: LayoutGroup = {
type: 'group',
id: uid(),
@@ -29,41 +28,29 @@ export function splitPanel(root: LayoutNode, panelId: string, direction: 'horizo
],
};
return group;
});
}
}
function mapNode(
node: LayoutNode,
fn: (node: LayoutNode, parent: LayoutGroup | null) => LayoutNode | null,
parent: LayoutGroup | null = null,
): LayoutNode {
const result = fn(node, parent);
if (result === null && parent !== null && node.type === 'panel') {
// Check if the target panel is a direct child and the directions match — append as sibling
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === panelId);
if (childIdx !== -1 && node.direction === direction) {
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
const idx = parent.children.findIndex((c) => c.node.id === node.id);
const newChildren = [
...parent.children.slice(0, idx + 1),
...node.children.slice(0, childIdx + 1),
{ node: newPanel, size: 0 },
...parent.children.slice(idx + 1),
...node.children.slice(childIdx + 1),
];
const size = 100 / newChildren.length;
parent.children = newChildren.map((c) => ({ ...c, size }));
return node;
return { ...node, children: newChildren.map((c) => ({ ...c, size })) };
}
if (result !== node) return result ?? node;
if (node.type === 'group') {
const newChildren = node.children.map((child) => ({
...child,
node: mapNode(child.node, fn, node),
}));
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
return { ...node, children: newChildren };
}
// Recurse into children
const newChildren = node.children.map((child) => ({
...child,
node: splitInner(child.node, panelId, direction),
}));
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
return { ...node, children: newChildren };
}
return node;
}
@@ -30,6 +30,7 @@ export type AppRegistryEntry = {
component: ComponentType<{ panelId: string }>;
transparent?: boolean;
fixedHeight?: number;
widget?: boolean;
};
export type AppRegistry = Record<string, AppRegistryEntry>;