) => {
- const target = ev.target as HTMLElement;
- if (!target.closest('[data-widget-header]') || target.closest('button')) return;
- layout.promoteMaster(id);
- };
-
- return (
-
-
- {widgets[layout.hyprland.master]}
-
-
- {layout.hyprland.stack.map((id) => (
-
onDoubleClick(id, ev)}>
- {widgets[id]}
-
- ))}
-
-
- );
-};
-
-// --- 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 }) => (
-
- {MODE_OPTIONS.map(({ mode: m, icon: Icon, label }) => (
-
- ))}
-
-);
diff --git a/src/workspaces/components/Workspace/LayoutEditor.tsx b/src/workspaces/components/Workspace/LayoutEditor.tsx
new file mode 100644
index 00000000..740e965c
--- /dev/null
+++ b/src/workspaces/components/Workspace/LayoutEditor.tsx
@@ -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) => (
+
+
+
+ {hasWidget && (
+
+ )}
+ {!isLastPanel && (
+
+ )}
+
+);
diff --git a/src/workspaces/components/Workspace/PanelSlot.tsx b/src/workspaces/components/Workspace/PanelSlot.tsx
new file mode 100644
index 00000000..02e3f948
--- /dev/null
+++ b/src/workspaces/components/Workspace/PanelSlot.tsx
@@ -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 (
+
+
onSetWidget(panel.id, type)} />
+
+
+
+ {!isLastPanel && (
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {editing && (
+
onSetWidget(panel.id, null)}
+ />
+ )}
+
+ );
+};
diff --git a/src/workspaces/components/Workspace/WidgetPicker.tsx b/src/workspaces/components/Workspace/WidgetPicker.tsx
new file mode 100644
index 00000000..d5c50f11
--- /dev/null
+++ b/src/workspaces/components/Workspace/WidgetPicker.tsx
@@ -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 (
+
+ {entries.map(([key, entry]) => (
+
+ ))}
+
+ );
+};
diff --git a/src/workspaces/components/Workspace/WorkspaceContext.ts b/src/workspaces/components/Workspace/WorkspaceContext.ts
new file mode 100644
index 00000000..c4d5e6be
--- /dev/null
+++ b/src/workspaces/components/Workspace/WorkspaceContext.ts
@@ -0,0 +1,17 @@
+import { createContext, useContext } from 'react';
+
+type WorkspaceContextValue = {
+ workspaceId: string | null;
+ cwd: string;
+ editing: boolean;
+};
+
+const WorkspaceContext = createContext({
+ workspaceId: null,
+ cwd: '~',
+ editing: false,
+});
+
+export const WorkspaceProvider = WorkspaceContext.Provider;
+
+export const useWorkspace = () => useContext(WorkspaceContext);
diff --git a/src/workspaces/components/Workspace/WorkspaceHeader.tsx b/src/workspaces/components/Workspace/WorkspaceHeader.tsx
new file mode 100644
index 00000000..7d318d08
--- /dev/null
+++ b/src/workspaces/components/Workspace/WorkspaceHeader.tsx
@@ -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) => (
+
+
{name}
+
+
+);
diff --git a/src/workspaces/components/Workspace/WorkspaceRenderer.tsx b/src/workspaces/components/Workspace/WorkspaceRenderer.tsx
new file mode 100644
index 00000000..ab7b3bdc
--- /dev/null
+++ b/src/workspaces/components/Workspace/WorkspaceRenderer.tsx
@@ -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 No widgets
;
+ }
+
+ const totalPanels = countPanels(layout);
+
+ return (
+
+
+
+ );
+};
+
+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>(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 (
+
+ );
+ }
+
+ return (
+
+ {node.children.map((child, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ );
+};
+
+type ChildEntryProps = {
+ index: number;
+ total: number;
+ children: React.ReactNode;
+};
+
+const ChildEntry = ({ index, total, children }: ChildEntryProps) => {
+ if (index === 0) return <>{children}>;
+ return (
+ <>
+
+ {children}
+ >
+ );
+};
diff --git a/src/workspaces/components/Workspace/WorkspaceView.tsx b/src/workspaces/components/Workspace/WorkspaceView.tsx
new file mode 100644
index 00000000..675a551d
--- /dev/null
+++ b/src/workspaces/components/Workspace/WorkspaceView.tsx
@@ -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 (
+
+
+
setEditing((v) => !v)} />
+
+
+
+
+
+ );
+};
diff --git a/src/workspaces/components/Workspace/index.ts b/src/workspaces/components/Workspace/index.ts
new file mode 100644
index 00000000..8b905ced
--- /dev/null
+++ b/src/workspaces/components/Workspace/index.ts
@@ -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';
diff --git a/src/workspaces/components/Workspace/layout-utils.ts b/src/workspaces/components/Workspace/layout-utils.ts
new file mode 100644
index 00000000..efe9d1a7
--- /dev/null
+++ b/src/workspaces/components/Workspace/layout-utils.ts
@@ -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 => 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);
+}
diff --git a/src/workspaces/components/Workspace/types.ts b/src/workspaces/components/Workspace/types.ts
new file mode 100644
index 00000000..52ef117d
--- /dev/null
+++ b/src/workspaces/components/Workspace/types.ts
@@ -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;
diff --git a/src/workspaces/components/package.json b/src/workspaces/components/package.json
index 86cba905..448e51b6 100644
--- a/src/workspaces/components/package.json
+++ b/src/workspaces/components/package.json
@@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./index.ts",
+ "./Workspace": "./Workspace/index.ts",
"./*": "./*.tsx",
"./ui/*": "./ui/*.tsx"
},