Workspaces layout, automation page
This commit is contained in:
@@ -1,167 +0,0 @@
|
||||
import type { CSSProperties, ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Minus, Plus, X } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { Card } from './Card';
|
||||
|
||||
type WidgetProps = ComponentPropsWithoutRef<'div'> & {
|
||||
title?: string;
|
||||
resizable?: boolean;
|
||||
collapsible?: boolean | { title: string; icon?: LucideIcon };
|
||||
moveable?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const Widget = ({ title, className, style, resizable, collapsible, moveable, onClose, children, ...props }: WidgetProps) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
naturalLeft: number;
|
||||
naturalTop: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
} | null>(null);
|
||||
const preToggleRect = useRef<{ left: number; top: number } | null>(null);
|
||||
|
||||
const HEADER_HEIGHT = 64;
|
||||
|
||||
const toggleMinimized = useCallback(() => {
|
||||
if (cardRef.current) {
|
||||
const rect = cardRef.current.getBoundingClientRect();
|
||||
preToggleRect.current = { left: rect.left, top: rect.top };
|
||||
}
|
||||
setMinimized((v) => !v);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!cardRef.current || !preToggleRect.current) return;
|
||||
const prev = preToggleRect.current;
|
||||
preToggleRect.current = null;
|
||||
const newRect = cardRef.current.getBoundingClientRect();
|
||||
setPosition((p) => ({
|
||||
x: p.x + (prev.left - newRect.left),
|
||||
y: p.y + (prev.top - newRect.top),
|
||||
}));
|
||||
}, [minimized]);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
if (!moveable) return;
|
||||
const target = ev.target as HTMLElement;
|
||||
const card = ev.currentTarget as HTMLElement;
|
||||
const isCardPadding = target === card;
|
||||
const isHeader = !isCardPadding && target.closest('[data-widget-header]') && !target.closest('button');
|
||||
if (!isCardPadding && !isHeader) return;
|
||||
if (isHeader && ev.detail === 2) {
|
||||
toggleMinimized();
|
||||
return;
|
||||
}
|
||||
const rect = card.getBoundingClientRect();
|
||||
dragRef.current = {
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
originX: position.x,
|
||||
originY: position.y,
|
||||
naturalLeft: rect.left - position.x,
|
||||
naturalTop: rect.top - position.y,
|
||||
cardWidth: rect.width,
|
||||
cardHeight: rect.height,
|
||||
};
|
||||
card.setPointerCapture(ev.pointerId);
|
||||
},
|
||||
[moveable, position],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((ev: ReactPointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const d = dragRef.current;
|
||||
const dx = ev.clientX - d.startX;
|
||||
const dy = ev.clientY - d.startY;
|
||||
const newX = d.originX + dx;
|
||||
const newY = d.originY + dy;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
setPosition({
|
||||
x: Math.min(Math.max(newX, -d.naturalLeft), vw - d.naturalLeft - d.cardWidth),
|
||||
y: Math.min(Math.max(newY, HEADER_HEIGHT - d.naturalTop), vh - d.naturalTop - d.cardHeight),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resizableStyle: CSSProperties | undefined = resizable ? { resize: 'both', overflow: 'auto' } : undefined;
|
||||
const moveableStyle: CSSProperties | undefined = moveable
|
||||
? { position: 'relative', transform: `translate(${position.x}px, ${position.y}px)` }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
'relative p-0',
|
||||
collapsible && 'pt-0',
|
||||
moveable && 'cursor-grab [&>*]:cursor-auto',
|
||||
className,
|
||||
minimized && '!h-auto !w-auto',
|
||||
)}
|
||||
style={{ ...resizableStyle, ...moveableStyle, ...style }}
|
||||
onPointerDown={moveable ? onPointerDown : undefined}
|
||||
onPointerMove={moveable ? onPointerMove : undefined}
|
||||
onPointerUp={moveable ? onPointerUp : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div data-widget-header className="flex h-8 items-center gap-12 px-3 select-none cursor-grab">
|
||||
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleMinimized()}
|
||||
>
|
||||
{minimized ? <Plus size={14} /> : <Minus size={14} />}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!minimized && (
|
||||
<>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 p-3 cursor-pointer"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{typeof collapsible === 'object' && collapsible.icon && (
|
||||
<collapsible.icon size={16} className="text-muted-foreground" />
|
||||
)}
|
||||
{typeof collapsible === 'object' && collapsible.title && (
|
||||
<span className="text-sm text-muted-foreground">{collapsible.title}</span>
|
||||
)}
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{collapsible ? expanded && <div className="px-5 pb-5">{children}</div> : children}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import type { WidgetRegistry } from './types';
|
||||
import type { AppRegistry } from './types';
|
||||
|
||||
type WidgetPickerProps = {
|
||||
registry: WidgetRegistry;
|
||||
onSelect: (widgetType: string) => void;
|
||||
type AppPickerProps = {
|
||||
registry: AppRegistry;
|
||||
onSelect: (appType: string) => void;
|
||||
};
|
||||
|
||||
export const WidgetPicker = ({ registry, onSelect }: WidgetPickerProps) => {
|
||||
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
||||
const entries = Object.entries(registry);
|
||||
|
||||
return (
|
||||
@@ -6,10 +6,10 @@ type LayoutEditorProps = {
|
||||
isLastPanel: boolean;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onClearWidget: () => void;
|
||||
onClearApp: () => void;
|
||||
};
|
||||
|
||||
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearWidget }: LayoutEditorProps) => (
|
||||
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"
|
||||
@@ -31,7 +31,7 @@ export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemov
|
||||
<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={onClearWidget}
|
||||
onClick={onClearApp}
|
||||
title="Clear Widget"
|
||||
>
|
||||
<X size={14} />
|
||||
|
||||
@@ -1,75 +1,107 @@
|
||||
import type { LayoutPanel, WidgetRegistry } from './types';
|
||||
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
|
||||
import { Card } from '../Card';
|
||||
import { WidgetPicker } from './WidgetPicker';
|
||||
import { AppPicker } from './AppPicker';
|
||||
import { LayoutEditor } from './LayoutEditor';
|
||||
|
||||
type PanelSlotProps = {
|
||||
panel: LayoutPanel;
|
||||
registry: WidgetRegistry;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
isLastPanel: boolean;
|
||||
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
};
|
||||
|
||||
export const PanelSlot = ({ panel, registry, editing, isLastPanel, onSetWidget, onSplit, onRemove }: PanelSlotProps) => {
|
||||
const entry = panel.widgetType ? registry[panel.widgetType] : null;
|
||||
const WidgetComponent = entry?.component;
|
||||
export const PanelSlot = ({ panel, registry, components, editing, 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 && !WidgetComponent) return null;
|
||||
|
||||
if (!WidgetComponent) {
|
||||
if (!editing && !AppComponent) {
|
||||
return (
|
||||
<Card className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4">
|
||||
<WidgetPicker registry={registry} onSelect={(type) => onSetWidget(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 && (
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!AppComponent) {
|
||||
return (
|
||||
<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-red-400/40 text-red-400/70 hover:bg-red-400/10 hover:text-red-400 cursor-pointer"
|
||||
onClick={() => onRemove(panel.id)}
|
||||
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')}
|
||||
>
|
||||
Remove
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry?.transparent) {
|
||||
return (
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||
>
|
||||
<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-hidden">
|
||||
<WidgetComponent panelId={panel.id} />
|
||||
</Card>
|
||||
{editing && (
|
||||
<LayoutEditor
|
||||
panelId={panel.id}
|
||||
hasWidget
|
||||
isLastPanel={isLastPanel}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onClearWidget={() => onSetWidget(panel.id, null)}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { LayoutNode, AppRegistry, PanelComponents } from './types';
|
||||
import { updateSizes } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
|
||||
type WorkspaceLayoutProps = {
|
||||
layout: LayoutNode;
|
||||
onLayoutChange: (layout: LayoutNode) => void;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
workspaceId?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components, workspaceId, cwd }: WorkspaceLayoutProps) => {
|
||||
const handleResized = useCallback(
|
||||
(groupId: string, sizes: number[]) => {
|
||||
onLayoutChange(updateSizes(layout, groupId, sizes));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', editing: false }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={false}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
|
||||
import type { LayoutNode, WidgetRegistry } from './types';
|
||||
import { pruneEmptyPanels, countPanels } from './layout-utils';
|
||||
import type { LayoutNode, AppRegistry, PanelComponents } from './types';
|
||||
import { countPanels } from './layout-utils';
|
||||
import { PanelSlot } from './PanelSlot';
|
||||
|
||||
type WorkspaceRendererProps = {
|
||||
layout: LayoutNode;
|
||||
registry: WidgetRegistry;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onResized: (groupId: string, sizes: number[]) => void;
|
||||
@@ -17,27 +18,24 @@ type WorkspaceRendererProps = {
|
||||
export const WorkspaceRenderer = ({
|
||||
layout,
|
||||
registry,
|
||||
components,
|
||||
editing,
|
||||
onSetWidget,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
onRemove,
|
||||
onResized,
|
||||
}: WorkspaceRendererProps) => {
|
||||
const displayLayout = editing ? layout : pruneEmptyPanels(layout);
|
||||
if (!displayLayout) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">No widgets</div>;
|
||||
}
|
||||
|
||||
const totalPanels = countPanels(layout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<LayoutNodeRenderer
|
||||
node={displayLayout}
|
||||
node={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
totalPanels={totalPanels}
|
||||
onSetWidget={onSetWidget}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
@@ -48,30 +46,42 @@ export const WorkspaceRenderer = ({
|
||||
|
||||
type LayoutNodeRendererProps = {
|
||||
node: LayoutNode;
|
||||
registry: WidgetRegistry;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
totalPanels: number;
|
||||
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onResized: (groupId: string, sizes: number[]) => void;
|
||||
};
|
||||
|
||||
const getFixedHeight = (node: LayoutNode, registry: AppRegistry): number | undefined => {
|
||||
if (node.type === 'panel' && node.appType) return registry[node.appType]?.fixedHeight;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const LayoutNodeRenderer = ({
|
||||
node,
|
||||
registry,
|
||||
components,
|
||||
editing,
|
||||
totalPanels,
|
||||
onSetWidget,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
onRemove,
|
||||
onResized,
|
||||
}: LayoutNodeRendererProps) => {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(sizes: number[]) => {
|
||||
if (node.type !== 'group') return;
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onResized(node.id, sizes);
|
||||
@@ -85,27 +95,56 @@ const LayoutNodeRenderer = ({
|
||||
<PanelSlot
|
||||
panel={node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
isLastPanel={totalPanels <= 1}
|
||||
onSetWidget={onSetWidget}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hasFixedChild = node.direction === 'vertical' && node.children.some((c) => getFixedHeight(c.node, registry) !== undefined);
|
||||
|
||||
if (hasFixedChild) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{node.children.map((child) => {
|
||||
const fixed = getFixedHeight(child.node, registry);
|
||||
return (
|
||||
<div key={child.node.id} className={fixed !== undefined ? 'shrink-0' : 'min-h-0 flex-1'} style={fixed !== undefined ? { height: fixed } : undefined}>
|
||||
<LayoutNodeRenderer
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup 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}>
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="h-full w-full">
|
||||
<LayoutNodeRenderer
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
totalPanels={totalPanels}
|
||||
onSetWidget={onSetWidget}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { LayoutNode, WorkspaceDefinition, WidgetRegistry } from './types';
|
||||
import { splitPanel, removePanel, setWidget, updateSizes, countPanels, hasAnyWidget } from './layout-utils';
|
||||
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, countPanels, hasAnyApp } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
@@ -10,15 +10,15 @@ type WorkspaceViewProps = {
|
||||
name?: string;
|
||||
layout: LayoutNode;
|
||||
onLayoutChange: (layout: LayoutNode) => void;
|
||||
registry: WidgetRegistry;
|
||||
registry: AppRegistry;
|
||||
};
|
||||
|
||||
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||
const [editing, setEditing] = useState(() => !hasAnyWidget(layout));
|
||||
const [editing, setEditing] = useState(() => !hasAnyApp(layout));
|
||||
|
||||
const handleSetWidget = useCallback(
|
||||
(panelId: string, widgetType: string | null) => {
|
||||
onLayoutChange(setWidget(layout, panelId, widgetType));
|
||||
const handleSetApp = useCallback(
|
||||
(panelId: string, appType: string | null) => {
|
||||
onLayoutChange(setApp(layout, panelId, appType));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
@@ -56,7 +56,7 @@ export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registr
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
editing={editing}
|
||||
onSetWidget={handleSetWidget}
|
||||
onSetApp={handleSetApp}
|
||||
onSplit={handleSplit}
|
||||
onRemove={handleRemove}
|
||||
onResized={handleResized}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WidgetRegistry, WidgetRegistryEntry } from './types';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels, hasAnyWidget } from './layout-utils';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents } from './types';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
export { WorkspaceView } from './WorkspaceView';
|
||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||
|
||||
@@ -6,14 +6,14 @@ const uid = () => `p-${Date.now()}-${++counter}`;
|
||||
export const createDefaultLayout = (): LayoutPanel => ({
|
||||
type: 'panel',
|
||||
id: uid(),
|
||||
widgetType: null,
|
||||
appType: null,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
|
||||
|
||||
if (parent && parent.direction === direction) {
|
||||
return null;
|
||||
@@ -40,7 +40,7 @@ function mapNode(
|
||||
const result = fn(node, parent);
|
||||
|
||||
if (result === null && parent !== null && node.type === 'panel') {
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
|
||||
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),
|
||||
@@ -104,13 +104,13 @@ export function removePanel(root: LayoutNode, panelId: string): LayoutNode {
|
||||
}
|
||||
}
|
||||
|
||||
export function setWidget(root: LayoutNode, panelId: string, widgetType: string | null): LayoutNode {
|
||||
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
|
||||
if (root.type === 'panel') {
|
||||
return root.id === panelId ? { ...root, widgetType } : root;
|
||||
return root.id === panelId ? { ...root, appType } : root;
|
||||
}
|
||||
const newChildren = root.children.map((child) => ({
|
||||
...child,
|
||||
node: setWidget(child.node, panelId, widgetType),
|
||||
node: setApp(child.node, panelId, appType),
|
||||
}));
|
||||
return { ...root, children: newChildren };
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]):
|
||||
|
||||
export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
|
||||
if (root.type === 'panel') {
|
||||
return root.widgetType ? root : null;
|
||||
return root.appType ? root : null;
|
||||
}
|
||||
|
||||
const pruned = root.children
|
||||
@@ -157,7 +157,7 @@ export function countPanels(node: LayoutNode): number {
|
||||
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
||||
}
|
||||
|
||||
export function hasAnyWidget(node: LayoutNode): boolean {
|
||||
if (node.type === 'panel') return node.widgetType !== null;
|
||||
return node.children.some((child) => hasAnyWidget(child.node));
|
||||
export function hasAnyApp(node: LayoutNode): boolean {
|
||||
if (node.type === 'panel') return node.appType !== null;
|
||||
return node.children.some((child) => hasAnyApp(child.node));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type LayoutGroup = {
|
||||
export type LayoutPanel = {
|
||||
type: 'panel';
|
||||
id: string;
|
||||
widgetType: string | null;
|
||||
appType: string | null;
|
||||
};
|
||||
|
||||
export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||
@@ -22,10 +22,14 @@ export type WorkspaceDefinition = {
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export type WidgetRegistryEntry = {
|
||||
export type AppRegistryEntry = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
component: ComponentType<{ panelId: string }>;
|
||||
transparent?: boolean;
|
||||
fixedHeight?: number;
|
||||
};
|
||||
|
||||
export type WidgetRegistry = Record<string, WidgetRegistryEntry>;
|
||||
export type AppRegistry = Record<string, AppRegistryEntry>;
|
||||
|
||||
export type PanelComponents = Record<string, ComponentType>;
|
||||
|
||||
Reference in New Issue
Block a user