Workspaces
This commit is contained in:
@@ -53,6 +53,8 @@ export function App() {
|
||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/workspaces" element={<Dashboard.WorkspaceListScreen />} />
|
||||
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
import { Workspace, createWorkspaceDefaults } from '@/components/Workspace';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { ChatLauncher } from './ChatLauncher';
|
||||
import { FileBrowserWidget as FileBrowser } from '@/Screens/Dashboard/Files';
|
||||
import { ChatHistoryWidget as ChatHistory } from '@/Screens/Dashboard/ChatHistory';
|
||||
import { Catalog } from 'sounds';
|
||||
import { WorkspaceView } from '@/components/Workspace';
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { widgetRegistry } from '@/Screens/Dashboard/Workspaces/widget-registry';
|
||||
|
||||
const WIDGET_IDS = ['chat-launcher', 'file-browser', 'chat-history', 'sound-library'] as const;
|
||||
const DEFAULTS = createWorkspaceDefaults([...WIDGET_IDS]);
|
||||
const DEFAULT_HOME_LAYOUT: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'home-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{
|
||||
size: 50,
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'home-left',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ size: 50, node: { type: 'panel', id: 'home-tl', widgetType: 'chat-launcher' } },
|
||||
{ size: 50, node: { type: 'panel', id: 'home-bl', widgetType: 'file-browser' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
size: 50,
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'home-right',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ size: 50, node: { type: 'panel', id: 'home-tr', widgetType: 'chat-history' } },
|
||||
{ size: 50, node: { type: 'panel', id: 'home-br', widgetType: 'sound-library' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const HomeScreen = () => {
|
||||
const [state, setState] = useUserState('home-layout', DEFAULTS);
|
||||
const [layout, setLayout] = useUserState<LayoutNode>('home-workspace-layout', DEFAULT_HOME_LAYOUT);
|
||||
|
||||
return (
|
||||
<Workspace
|
||||
widgets={{
|
||||
'chat-launcher': <ChatLauncher />,
|
||||
'file-browser': <FileBrowser />,
|
||||
'chat-history': <ChatHistory />,
|
||||
'sound-library': <Catalog />,
|
||||
}}
|
||||
state={state}
|
||||
onChange={setState}
|
||||
/>
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView
|
||||
workspace={null}
|
||||
name="Home"
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
registry={widgetRegistry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,7 +91,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
};
|
||||
|
||||
|
||||
import { Terminal, TerminalSquare, FileText, FolderOpen, Code } from 'lucide-react';
|
||||
import { Terminal, TerminalSquare, FileText, FolderOpen, Code, LayoutGrid } from 'lucide-react';
|
||||
import { Sparkles, ClipboardList, ScrollText, Workflow } from 'lucide-react';
|
||||
|
||||
export const dockItems: DockItem[] = [
|
||||
@@ -104,4 +104,5 @@ export const dockItems: DockItem[] = [
|
||||
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
|
||||
{ label: 'Processes', to: '/processes', icon: Workflow, color: '#2dd4bf' },
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Plus, Trash2, LayoutGrid, ArrowRight } from 'lucide-react';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||
import { Widget } from '@/components/Widget';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export const WorkspaceListScreen = () => {
|
||||
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const createWorkspace = () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
const ws: WorkspaceDefinition = {
|
||||
id: `ws-${Date.now()}`,
|
||||
name,
|
||||
cwd: '~',
|
||||
};
|
||||
setWorkspaces((prev) => [...prev, ws]);
|
||||
setNewName('');
|
||||
};
|
||||
|
||||
const deleteWorkspace = (id: string) => {
|
||||
setWorkspaces((prev) => prev.filter((ws) => ws.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-start justify-center p-8">
|
||||
<Widget title="Workspaces" className="w-full max-w-lg">
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(ev) => setNewName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
createWorkspace();
|
||||
}
|
||||
}}
|
||||
placeholder="New workspace name..."
|
||||
className="flex-1 rounded-lg border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||
/>
|
||||
<Button
|
||||
onClick={createWorkspace}
|
||||
disabled={!newName.trim()}
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{workspaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-duck-dark/40">
|
||||
<LayoutGrid className="h-8 w-8" />
|
||||
<p className="text-sm">No workspaces yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{workspaces.map((ws) => (
|
||||
<li
|
||||
key={ws.id}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-duck-dark/5 group"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||
<Link to={`/workspaces/${ws.id}`} className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-duck-dark truncate block">{ws.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteWorkspace(ws.id)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<Link
|
||||
to={`/workspaces/${ws.id}`}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Link } from 'react-router';
|
||||
import { LayoutGrid, ArrowRight } from 'lucide-react';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||
|
||||
export const WorkspaceListWidget = () => {
|
||||
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||
{workspaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-duck-dark/40">
|
||||
<LayoutGrid className="h-6 w-6" />
|
||||
<p className="text-xs">No workspaces</p>
|
||||
<Link to="/workspaces" className="text-xs text-duck-teal hover:underline">
|
||||
Create one
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{workspaces.map((ws) => (
|
||||
<li key={ws.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
|
||||
<Link to={`/workspaces/${ws.id}`} className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<LayoutGrid className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||
<span className="text-sm text-duck-dark truncate">{ws.name}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/workspaces/${ws.id}`}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||
import { widgetRegistry } from './widget-registry';
|
||||
|
||||
export const WorkspaceScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const workspace = workspaces.find((ws) => ws.id === id);
|
||||
|
||||
if (!workspace) return <Navigate to="/workspaces" replace />;
|
||||
|
||||
return <WorkspaceScreenInner workspace={workspace} />;
|
||||
};
|
||||
|
||||
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const [layout, setLayout] = useUserState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
registry={widgetRegistry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { WorkspaceListScreen } from './WorkspaceListScreen';
|
||||
export { WorkspaceScreen } from './WorkspaceScreen';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, LayoutGrid } from 'lucide-react';
|
||||
import type { WidgetRegistry } from '@/components/Workspace';
|
||||
import { CodeEditorView } from 'widgets/CodeEditor';
|
||||
import { TerminalView } from 'widgets/Terminal';
|
||||
import { ChatLauncher } from '../Home/ChatLauncher';
|
||||
import { ChatHistoryWidget as ChatHistory } from '../ChatHistory';
|
||||
import { FileBrowserWidget as FileBrowser } from '../Files';
|
||||
import { Catalog } from 'sounds';
|
||||
import { WorkspaceListWidget } from './WorkspaceListWidget';
|
||||
|
||||
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||
|
||||
const TerminalWrapper = () => <TerminalView className="h-full w-full p-2" />;
|
||||
|
||||
export const widgetRegistry: WidgetRegistry = {
|
||||
'chat-launcher': { name: 'Chat', icon: MessageSquare, component: () => <ChatLauncher /> },
|
||||
'file-browser': { name: 'File Browser', icon: FolderOpen, component: () => <FileBrowser /> },
|
||||
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> },
|
||||
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
|
||||
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
|
||||
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper },
|
||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListWidget /> },
|
||||
};
|
||||
@@ -14,3 +14,4 @@ export * from './Terminal';
|
||||
export * from './Files';
|
||||
export * from './ChatHistory';
|
||||
export * from './CodeEditor';
|
||||
export * from './Workspaces';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Minus, Plus, X } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { Card } from './Card';
|
||||
|
||||
@@ -10,9 +10,10 @@ type WidgetProps = ComponentPropsWithoutRef<'div'> & {
|
||||
resizable?: boolean;
|
||||
collapsible?: boolean | { title: string; icon?: LucideIcon };
|
||||
moveable?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const Widget = ({ title, className, style, resizable, collapsible, moveable, children, ...props }: WidgetProps) => {
|
||||
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 });
|
||||
@@ -120,13 +121,24 @@ export const Widget = ({ title, className, style, resizable, collapsible, moveab
|
||||
>
|
||||
<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>}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleMinimized()}
|
||||
>
|
||||
{minimized ? <Plus size={14} /> : <Minus size={14} />}
|
||||
</button>
|
||||
<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 && (
|
||||
<>
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
import type { ReactNode, PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Columns, Grid2x2, Move } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
|
||||
// --- Public Types ---
|
||||
|
||||
export type LayoutMode = 'free' | 'spectacle' | 'hyprland';
|
||||
export type FreePosition = { x: number; y: number; w: number; h: number };
|
||||
export type SpectacleZone = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
|
||||
export type WorkspaceState = {
|
||||
mode: LayoutMode;
|
||||
free: Record<string, FreePosition>;
|
||||
spectacle: Record<string, SpectacleZone>;
|
||||
hyprland: { master: string; stack: string[] };
|
||||
};
|
||||
|
||||
type WorkspaceOnChange = (update: WorkspaceState | ((prev: WorkspaceState) => WorkspaceState)) => void;
|
||||
|
||||
type WorkspaceProps = {
|
||||
widgets: Record<string, ReactNode>;
|
||||
state: WorkspaceState;
|
||||
onChange: WorkspaceOnChange;
|
||||
};
|
||||
|
||||
// --- Defaults Helper ---
|
||||
|
||||
const SPECTACLE_ZONES: SpectacleZone[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||
|
||||
export function createWorkspaceDefaults(widgetIds: string[]): WorkspaceState {
|
||||
return {
|
||||
mode: 'spectacle',
|
||||
free: Object.fromEntries(
|
||||
widgetIds.map((id, i) => [
|
||||
id,
|
||||
{ x: 40 + (i % 2) * 440, y: 40 + Math.floor(i / 2) * 320, w: 420, h: 300 },
|
||||
]),
|
||||
),
|
||||
spectacle: Object.fromEntries(
|
||||
widgetIds.map((id, i) => [id, SPECTACLE_ZONES[i % SPECTACLE_ZONES.length]!]),
|
||||
),
|
||||
hyprland: {
|
||||
master: widgetIds[0]!,
|
||||
stack: widgetIds.slice(1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Internal Layout Hook ---
|
||||
|
||||
function useWorkspaceLayout(widgetIds: string[], state: WorkspaceState, onChange: WorkspaceOnChange) {
|
||||
const [zOrder, setZOrder] = useState<string[]>(() => [...widgetIds]);
|
||||
const [dragOverride, setDragOverride] = useState<{ id: string; pos: FreePosition } | null>(null);
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode: LayoutMode) => onChange((prev) => ({ ...prev, mode })),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const setFreePositions = useCallback(
|
||||
(positions: Record<string, FreePosition>) =>
|
||||
onChange((prev) => ({ ...prev, mode: 'free' as const, free: positions })),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateFreePosition = useCallback(
|
||||
(id: string, pos: Partial<FreePosition>) =>
|
||||
onChange((prev) => {
|
||||
const base = prev.free[id] ?? { x: 0, y: 0, w: 420, h: 300 };
|
||||
const updated: FreePosition = { ...base, ...pos };
|
||||
return { ...prev, free: { ...prev.free, [id]: updated } };
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const swapZones = useCallback(
|
||||
(a: string, b: string) => {
|
||||
if (a === b) return;
|
||||
onChange((prev) => {
|
||||
const zoneA = prev.spectacle[a];
|
||||
const zoneB = prev.spectacle[b];
|
||||
if (!zoneA || !zoneB) return prev;
|
||||
return { ...prev, spectacle: { ...prev.spectacle, [a]: zoneB, [b]: zoneA } };
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const promoteMaster = useCallback(
|
||||
(id: string) =>
|
||||
onChange((prev) => {
|
||||
if (prev.hyprland.master === id) return prev;
|
||||
const oldMaster = prev.hyprland.master;
|
||||
return {
|
||||
...prev,
|
||||
hyprland: {
|
||||
master: id,
|
||||
stack: prev.hyprland.stack.map((s) => (s === id ? oldMaster : s)),
|
||||
},
|
||||
};
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const bringToFront = useCallback(
|
||||
(id: string) =>
|
||||
setZOrder((prev) => {
|
||||
if (prev[prev.length - 1] === id) return prev;
|
||||
return [...prev.filter((w) => w !== id), id];
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
mode: state.mode,
|
||||
setMode,
|
||||
setFreePositions,
|
||||
free: state.free,
|
||||
updateFreePosition,
|
||||
spectacle: state.spectacle,
|
||||
swapZones,
|
||||
hyprland: state.hyprland,
|
||||
promoteMaster,
|
||||
zOrder,
|
||||
bringToFront,
|
||||
dragOverride,
|
||||
setDragOverride,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Workspace Component ---
|
||||
|
||||
export const Workspace = ({ widgets, state, onChange }: WorkspaceProps) => {
|
||||
const widgetIds = Object.keys(widgets);
|
||||
const layout = useWorkspaceLayout(widgetIds, state, onChange);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleModeChange = (newMode: LayoutMode) => {
|
||||
if (newMode === 'free' && canvasRef.current) {
|
||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||
const positions: Record<string, FreePosition> = {};
|
||||
for (const id of widgetIds) {
|
||||
const wrapper = canvasRef.current.querySelector<HTMLElement>(`[data-widget-id="${id}"]`);
|
||||
const el = (wrapper?.firstElementChild as HTMLElement | null) ?? wrapper;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
positions[id] = {
|
||||
x: rect.left - canvasRect.left,
|
||||
y: rect.top - canvasRect.top,
|
||||
w: rect.width,
|
||||
h: rect.height,
|
||||
};
|
||||
} else {
|
||||
positions[id] = layout.free[id] ?? { x: 0, y: 0, w: 420, h: 300 };
|
||||
}
|
||||
}
|
||||
layout.setFreePositions(positions);
|
||||
} else {
|
||||
layout.setMode(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} className="relative h-full w-full overflow-hidden">
|
||||
{layout.mode === 'free' && (
|
||||
<FreeLayout widgets={widgets} widgetIds={widgetIds} layout={layout} canvasRef={canvasRef} />
|
||||
)}
|
||||
{layout.mode === 'spectacle' && (
|
||||
<SpectacleLayout widgets={widgets} widgetIds={widgetIds} layout={layout} canvasRef={canvasRef} />
|
||||
)}
|
||||
{layout.mode === 'hyprland' && <HyprlandLayout widgets={widgets} layout={layout} />}
|
||||
<ModeSwitcher mode={layout.mode} onChange={handleModeChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub-component Props ---
|
||||
|
||||
type LayoutProps = {
|
||||
widgets: Record<string, ReactNode>;
|
||||
widgetIds: string[];
|
||||
layout: ReturnType<typeof useWorkspaceLayout>;
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
// --- Free Mode ---
|
||||
|
||||
const FreeLayout = ({ widgets, widgetIds, layout, canvasRef }: LayoutProps) => {
|
||||
const dragRef = useRef<{
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
w: number;
|
||||
h: number;
|
||||
} | null>(null);
|
||||
|
||||
const onPointerDown = (id: string, ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
layout.bringToFront(id);
|
||||
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
if (ev.detail === 2) return;
|
||||
|
||||
const pos = layout.free[id];
|
||||
if (!pos) return;
|
||||
dragRef.current = {
|
||||
id,
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
originX: pos.x,
|
||||
originY: pos.y,
|
||||
w: pos.w,
|
||||
h: pos.h,
|
||||
};
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current.getBoundingClientRect();
|
||||
const newX = Math.max(0, Math.min(d.originX + ev.clientX - d.startX, canvas.width - d.w));
|
||||
const newY = Math.max(0, Math.min(d.originY + ev.clientY - d.startY, canvas.height - 40));
|
||||
layout.setDragOverride({ id: d.id, pos: { x: newX, y: newY, w: d.w, h: d.h } });
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
if (layout.dragOverride?.id === d.id) {
|
||||
layout.updateFreePosition(d.id, layout.dragOverride.pos);
|
||||
layout.setDragOverride(null);
|
||||
}
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{widgetIds.map((id) => {
|
||||
const pos = layout.dragOverride?.id === id ? layout.dragOverride.pos : layout.free[id];
|
||||
if (!pos) return null;
|
||||
const zIndex = layout.zOrder.indexOf(id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-widget-id={id}
|
||||
className="absolute"
|
||||
style={{ left: pos.x, top: pos.y, width: pos.w, height: pos.h, zIndex }}
|
||||
onPointerDown={(ev) => onPointerDown(id, ev)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Spectacle Mode ---
|
||||
|
||||
const ZONE_GRID_AREA: Record<SpectacleZone, string> = {
|
||||
'top-left': '1 / 1 / 2 / 2',
|
||||
'top-right': '1 / 2 / 2 / 3',
|
||||
'bottom-left': '2 / 1 / 3 / 2',
|
||||
'bottom-right': '2 / 2 / 3 / 3',
|
||||
};
|
||||
|
||||
const SpectacleLayout = ({ widgets, widgetIds, layout, canvasRef }: LayoutProps) => {
|
||||
const [dragTarget, setDragTarget] = useState<SpectacleZone | null>(null);
|
||||
const dragRef = useRef<{ id: string } | null>(null);
|
||||
|
||||
const getZoneFromPoint = (clientX: number, clientY: number): SpectacleZone | null => {
|
||||
if (!canvasRef.current) return null;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null;
|
||||
const col = x < rect.width / 2 ? 'left' : 'right';
|
||||
const row = y < rect.height / 2 ? 'top' : 'bottom';
|
||||
return `${row}-${col}` as SpectacleZone;
|
||||
};
|
||||
|
||||
const widgetByZone: Record<string, string> = {};
|
||||
for (const id of widgetIds) {
|
||||
const zone = layout.spectacle[id];
|
||||
if (zone) widgetByZone[zone] = id;
|
||||
}
|
||||
|
||||
const onPointerDown = (id: string, ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
if (ev.detail === 2) return;
|
||||
|
||||
dragRef.current = { id };
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
const zone = getZoneFromPoint(ev.clientX, ev.clientY);
|
||||
setDragTarget(zone && zone !== layout.spectacle[dragRef.current.id] ? zone : null);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (d && dragTarget) {
|
||||
const occupant = widgetByZone[dragTarget];
|
||||
if (occupant && occupant !== d.id) {
|
||||
layout.swapZones(d.id, occupant);
|
||||
}
|
||||
}
|
||||
setDragTarget(null);
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid h-full w-full gap-2 p-2"
|
||||
style={{ gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr' }}
|
||||
>
|
||||
{widgetIds.map((id) => {
|
||||
const zone = layout.spectacle[id];
|
||||
if (!zone) return null;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-widget-id={id}
|
||||
className="h-full w-full overflow-hidden"
|
||||
style={{ gridArea: ZONE_GRID_AREA[zone] }}
|
||||
onPointerDown={(ev) => onPointerDown(id, ev)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dragTarget && (
|
||||
<div
|
||||
className="pointer-events-none z-10 rounded-lg border-2 border-duck-teal/30 bg-duck-teal/10"
|
||||
style={{ gridArea: ZONE_GRID_AREA[dragTarget] }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Hyprland Mode ---
|
||||
|
||||
const HyprlandLayout = ({ widgets, layout }: Omit<LayoutProps, 'canvasRef' | 'widgetIds'>) => {
|
||||
const onDoubleClick = (id: string, ev: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
layout.promoteMaster(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-2 p-2">
|
||||
<div className="min-w-0 flex-1" data-widget-id={layout.hyprland.master}>
|
||||
{widgets[layout.hyprland.master]}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{layout.hyprland.stack.map((id) => (
|
||||
<div key={id} className="min-h-0 flex-1" data-widget-id={id} onDoubleClick={(ev) => onDoubleClick(id, ev)}>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Mode Switcher ---
|
||||
|
||||
const MODE_OPTIONS: { mode: LayoutMode; icon: typeof Move; label: string }[] = [
|
||||
{ mode: 'spectacle', icon: Grid2x2, label: 'Snap' },
|
||||
{ mode: 'free', icon: Move, label: 'Free' },
|
||||
{ mode: 'hyprland', icon: Columns, label: 'Auto' },
|
||||
];
|
||||
|
||||
const ModeSwitcher = ({ mode, onChange }: { mode: LayoutMode; onChange: (m: LayoutMode) => void }) => (
|
||||
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-1 rounded-full border border-border bg-background/80 p-1 backdrop-blur-sm">
|
||||
{MODE_OPTIONS.map(({ mode: m, icon: Icon, label }) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => onChange(m)}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all cursor-pointer',
|
||||
mode === m ? 'bg-white text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
onClearWidget: () => void;
|
||||
};
|
||||
|
||||
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearWidget }: 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={onClearWidget}
|
||||
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>
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { LayoutPanel, WidgetRegistry } from './types';
|
||||
import { WidgetPicker } from './WidgetPicker';
|
||||
import { LayoutEditor } from './LayoutEditor';
|
||||
|
||||
type PanelSlotProps = {
|
||||
panel: LayoutPanel;
|
||||
registry: WidgetRegistry;
|
||||
editing: boolean;
|
||||
isLastPanel: boolean;
|
||||
onSetWidget: (panelId: string, widgetType: 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;
|
||||
|
||||
if (!editing && !WidgetComponent) return null;
|
||||
|
||||
if (!WidgetComponent) {
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4 rounded-lg border backdrop-blur-xl"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||
>
|
||||
<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 && (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||
>
|
||||
<div className="h-full w-full [&>*]:!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" style={{ ['--card-bg' as string]: 'transparent', ['--card-grid-color' as string]: 'transparent' }}>
|
||||
<WidgetComponent panelId={panel.id} />
|
||||
</div>
|
||||
{editing && (
|
||||
<LayoutEditor
|
||||
panelId={panel.id}
|
||||
hasWidget
|
||||
isLastPanel={isLastPanel}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onClearWidget={() => onSetWidget(panel.id, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { WidgetRegistry } from './types';
|
||||
|
||||
type WidgetPickerProps = {
|
||||
registry: WidgetRegistry;
|
||||
onSelect: (widgetType: string) => void;
|
||||
};
|
||||
|
||||
export const WidgetPicker = ({ registry, onSelect }: WidgetPickerProps) => {
|
||||
const entries = Object.entries(registry);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||
{entries.map(([key, entry]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-white/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-white/90 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<entry.icon className="h-5 w-5" />
|
||||
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
|
||||
export const useWorkspace = () => useContext(WorkspaceContext);
|
||||
@@ -0,0 +1,25 @@
|
||||
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>
|
||||
);
|
||||
@@ -0,0 +1,135 @@
|
||||
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 { PanelSlot } from './PanelSlot';
|
||||
|
||||
type WorkspaceRendererProps = {
|
||||
layout: LayoutNode;
|
||||
registry: WidgetRegistry;
|
||||
editing: boolean;
|
||||
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onResized: (groupId: string, sizes: number[]) => void;
|
||||
};
|
||||
|
||||
export const WorkspaceRenderer = ({
|
||||
layout,
|
||||
registry,
|
||||
editing,
|
||||
onSetWidget,
|
||||
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}
|
||||
registry={registry}
|
||||
editing={editing}
|
||||
totalPanels={totalPanels}
|
||||
onSetWidget={onSetWidget}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LayoutNodeRendererProps = {
|
||||
node: LayoutNode;
|
||||
registry: WidgetRegistry;
|
||||
editing: boolean;
|
||||
totalPanels: number;
|
||||
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onResized: (groupId: string, sizes: number[]) => void;
|
||||
};
|
||||
|
||||
const LayoutNodeRenderer = ({
|
||||
node,
|
||||
registry,
|
||||
editing,
|
||||
totalPanels,
|
||||
onSetWidget,
|
||||
onSplit,
|
||||
onRemove,
|
||||
onResized,
|
||||
}: LayoutNodeRendererProps) => {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(sizes: number[]) => {
|
||||
if (node.type !== 'group') return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onResized(node.id, sizes);
|
||||
}, 500);
|
||||
},
|
||||
[node, onResized],
|
||||
);
|
||||
|
||||
if (node.type === 'panel') {
|
||||
return (
|
||||
<PanelSlot
|
||||
panel={node}
|
||||
registry={registry}
|
||||
editing={editing}
|
||||
isLastPanel={totalPanels <= 1}
|
||||
onSetWidget={onSetWidget}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<LayoutNodeRenderer
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
editing={editing}
|
||||
totalPanels={totalPanels}
|
||||
onSetWidget={onSetWidget}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onResized={onResized}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ChildEntry>
|
||||
))}
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
type ChildEntryProps = {
|
||||
index: number;
|
||||
total: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ChildEntry = ({ index, total, children }: ChildEntryProps) => {
|
||||
if (index === 0) return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
<ResizableHandle className="bg-transparent after:bg-transparent" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { LayoutNode, WorkspaceDefinition, WidgetRegistry } from './types';
|
||||
import { splitPanel, removePanel, setWidget, 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: WidgetRegistry;
|
||||
};
|
||||
|
||||
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const handleSetWidget = useCallback(
|
||||
(panelId: string, widgetType: string | null) => {
|
||||
onLayoutChange(setWidget(layout, panelId, widgetType));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleSplit = useCallback(
|
||||
(panelId: string, direction: 'horizontal' | 'vertical') => {
|
||||
onLayoutChange(splitPanel(layout, panelId, direction));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(panelId: string) => {
|
||||
if (countPanels(layout) <= 1) return;
|
||||
onLayoutChange(removePanel(layout, panelId));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleResized = useCallback(
|
||||
(groupId: string, sizes: number[]) => {
|
||||
onLayoutChange(updateSizes(layout, groupId, sizes));
|
||||
},
|
||||
[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}
|
||||
onSetWidget={handleSetWidget}
|
||||
onSplit={handleSplit}
|
||||
onRemove={handleRemove}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WidgetRegistry, WidgetRegistryEntry } from './types';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels } from './layout-utils';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
export { WorkspaceView } from './WorkspaceView';
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { LayoutNode, LayoutPanel, LayoutGroup } from './types';
|
||||
|
||||
let counter = 0;
|
||||
const uid = () => `p-${Date.now()}-${++counter}`;
|
||||
|
||||
export const createDefaultLayout = (): LayoutPanel => ({
|
||||
type: 'panel',
|
||||
id: uid(),
|
||||
widgetType: 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 };
|
||||
|
||||
if (parent && parent.direction === direction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group: LayoutGroup = {
|
||||
type: 'group',
|
||||
id: uid(),
|
||||
direction,
|
||||
children: [
|
||||
{ node, size: 50 },
|
||||
{ node: newPanel, size: 50 },
|
||||
],
|
||||
};
|
||||
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') {
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
|
||||
const idx = parent.children.findIndex((c) => c.node.id === node.id);
|
||||
const newChildren = [
|
||||
...parent.children.slice(0, idx + 1),
|
||||
{ node: newPanel, size: 0 },
|
||||
...parent.children.slice(idx + 1),
|
||||
];
|
||||
const size = 100 / newChildren.length;
|
||||
parent.children = newChildren.map((c) => ({ ...c, size }));
|
||||
return node;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function removePanel(root: LayoutNode, panelId: string): LayoutNode {
|
||||
if (root.type === 'panel') return root;
|
||||
if (countPanels(root) <= 1) return root;
|
||||
|
||||
return removePanelInner(root);
|
||||
|
||||
function removePanelInner(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'panel') return node;
|
||||
|
||||
const filtered = node.children.filter((c) => !(c.node.type === 'panel' && c.node.id === panelId));
|
||||
|
||||
if (filtered.length < node.children.length) {
|
||||
if (filtered.length === 0) return createDefaultLayout();
|
||||
if (filtered.length === 1) return filtered[0]!.node;
|
||||
const total = filtered.reduce((sum, c) => sum + c.size, 0);
|
||||
return {
|
||||
...node,
|
||||
children: filtered.map((c) => ({ ...c, size: (c.size / total) * 100 })),
|
||||
};
|
||||
}
|
||||
|
||||
const newChildren = node.children.map((child) => ({
|
||||
...child,
|
||||
node: removePanelInner(child.node),
|
||||
}));
|
||||
|
||||
const unwrapped = newChildren.map((child) => {
|
||||
if (child.node.type === 'group' && child.node.children.length === 1) {
|
||||
return { ...child, node: child.node.children[0]!.node };
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
return { ...node, children: unwrapped };
|
||||
}
|
||||
}
|
||||
|
||||
export function setWidget(root: LayoutNode, panelId: string, widgetType: string | null): LayoutNode {
|
||||
if (root.type === 'panel') {
|
||||
return root.id === panelId ? { ...root, widgetType } : root;
|
||||
}
|
||||
const newChildren = root.children.map((child) => ({
|
||||
...child,
|
||||
node: setWidget(child.node, panelId, widgetType),
|
||||
}));
|
||||
return { ...root, children: newChildren };
|
||||
}
|
||||
|
||||
export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]): LayoutNode {
|
||||
if (root.type === 'panel') return root;
|
||||
if (root.id === groupId) {
|
||||
return {
|
||||
...root,
|
||||
children: root.children.map((child, i) => ({ ...child, size: sizes[i] ?? child.size })),
|
||||
};
|
||||
}
|
||||
const newChildren = root.children.map((child) => ({
|
||||
...child,
|
||||
node: updateSizes(child.node, groupId, sizes),
|
||||
}));
|
||||
return { ...root, children: newChildren };
|
||||
}
|
||||
|
||||
export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
|
||||
if (root.type === 'panel') {
|
||||
return root.widgetType ? root : null;
|
||||
}
|
||||
|
||||
const pruned = root.children
|
||||
.map((child) => {
|
||||
const node = pruneEmptyPanels(child.node);
|
||||
return node ? { ...child, node } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (pruned.length === 0) return null;
|
||||
if (pruned.length === 1) return pruned[0]!.node;
|
||||
|
||||
const total = pruned.reduce((sum, c) => sum + c.size, 0);
|
||||
return {
|
||||
...root,
|
||||
children: pruned.map((c) => ({ ...c, size: (c.size / total) * 100 })),
|
||||
};
|
||||
}
|
||||
|
||||
export function countPanels(node: LayoutNode): number {
|
||||
if (node.type === 'panel') return 1;
|
||||
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type LayoutGroup = {
|
||||
type: 'group';
|
||||
id: string;
|
||||
direction: 'horizontal' | 'vertical';
|
||||
children: { node: LayoutNode; size: number }[];
|
||||
};
|
||||
|
||||
export type LayoutPanel = {
|
||||
type: 'panel';
|
||||
id: string;
|
||||
widgetType: string | null;
|
||||
};
|
||||
|
||||
export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||
|
||||
export type WorkspaceDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export type WidgetRegistryEntry = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
component: ComponentType<{ panelId: string }>;
|
||||
};
|
||||
|
||||
export type WidgetRegistry = Record<string, WidgetRegistryEntry>;
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./Workspace": "./Workspace/index.ts",
|
||||
"./*": "./*.tsx",
|
||||
"./ui/*": "./ui/*.tsx"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user