Workspaces

This commit is contained in:
2026-02-18 02:36:15 +00:00
parent c2660cd125
commit 826904d74f
22 changed files with 851 additions and 437 deletions
+2
View File
@@ -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';
+21 -9
View File
@@ -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 && (
<>
-409
View File
@@ -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>;
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./index.ts",
"./Workspace": "./Workspace/index.ts",
"./*": "./*.tsx",
"./ui/*": "./ui/*.tsx"
},