diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 7ae93909..68734820 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -53,6 +53,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx index f13c64db..8626b3a1 100644 --- a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx @@ -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('home-workspace-layout', DEFAULT_HOME_LAYOUT); return ( - , - 'file-browser': , - 'chat-history': , - 'sound-library': , - }} - state={state} - onChange={setState} - /> +
+ +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index abae49fc..3d458618 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -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' }, ]; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx new file mode 100644 index 00000000..350a6596 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx @@ -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('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 ( +
+ +
+
+ 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" + /> + +
+ + {workspaces.length === 0 ? ( +
+ +

No workspaces yet

+
+ ) : ( +
    + {workspaces.map((ws) => ( +
  • + + + {ws.name} + + + + + +
  • + ))} +
+ )} +
+
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListWidget.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListWidget.tsx new file mode 100644 index 00000000..09fefc58 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListWidget.tsx @@ -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('workspaces', []); + + return ( +
+ {workspaces.length === 0 ? ( +
+ +

No workspaces

+ + Create one + +
+ ) : ( +
    + {workspaces.map((ws) => ( +
  • + + + {ws.name} + + + + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx new file mode 100644 index 00000000..606ee643 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx @@ -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('workspaces', []); + const workspace = workspaces.find((ws) => ws.id === id); + + if (!workspace) return ; + + return ; +}; + +const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => { + const [layout, setLayout] = useUserState(`ws-layout-${workspace.id}`, createDefaultLayout()); + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx new file mode 100644 index 00000000..9f133b88 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx @@ -0,0 +1,2 @@ +export { WorkspaceListScreen } from './WorkspaceListScreen'; +export { WorkspaceScreen } from './WorkspaceScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/widget-registry.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/widget-registry.tsx new file mode 100644 index 00000000..6c0caeec --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/widget-registry.tsx @@ -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 = () => ; + +const TerminalWrapper = () => ; + +export const widgetRegistry: WidgetRegistry = { + 'chat-launcher': { name: 'Chat', icon: MessageSquare, component: () => }, + 'file-browser': { name: 'File Browser', icon: FolderOpen, component: () => }, + 'chat-history': { name: 'Chat History', icon: History, component: () => }, + 'sound-library': { name: 'Sound Library', icon: Music, component: () => }, + 'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper }, + 'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper }, + 'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => }, +}; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index a1c757be..5b236c8c 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -14,3 +14,4 @@ export * from './Terminal'; export * from './Files'; export * from './ChatHistory'; export * from './CodeEditor'; +export * from './Workspaces'; diff --git a/src/workspaces/components/Widget.tsx b/src/workspaces/components/Widget.tsx index a6dabf3b..cb46d3f0 100644 --- a/src/workspaces/components/Widget.tsx +++ b/src/workspaces/components/Widget.tsx @@ -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 >
{title && {title}} - +
+ + {onClose && ( + + )} +
{!minimized && ( <> diff --git a/src/workspaces/components/Workspace.tsx b/src/workspaces/components/Workspace.tsx deleted file mode 100644 index 2c3339c0..00000000 --- a/src/workspaces/components/Workspace.tsx +++ /dev/null @@ -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; - spectacle: Record; - hyprland: { master: string; stack: string[] }; -}; - -type WorkspaceOnChange = (update: WorkspaceState | ((prev: WorkspaceState) => WorkspaceState)) => void; - -type WorkspaceProps = { - widgets: Record; - 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(() => [...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) => - onChange((prev) => ({ ...prev, mode: 'free' as const, free: positions })), - [onChange], - ); - - const updateFreePosition = useCallback( - (id: string, pos: Partial) => - 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(null); - - const handleModeChange = (newMode: LayoutMode) => { - if (newMode === 'free' && canvasRef.current) { - const canvasRect = canvasRef.current.getBoundingClientRect(); - const positions: Record = {}; - for (const id of widgetIds) { - const wrapper = canvasRef.current.querySelector(`[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 ( -
- {layout.mode === 'free' && ( - - )} - {layout.mode === 'spectacle' && ( - - )} - {layout.mode === 'hyprland' && } - -
- ); -}; - -// --- Sub-component Props --- - -type LayoutProps = { - widgets: Record; - widgetIds: string[]; - layout: ReturnType; - canvasRef: React.RefObject; -}; - -// --- 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) => { - 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) => { - 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 ( -
onPointerDown(id, ev)} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - {widgets[id]} -
- ); - })} - - ); -}; - -// --- Spectacle Mode --- - -const ZONE_GRID_AREA: Record = { - '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(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 = {}; - for (const id of widgetIds) { - const zone = layout.spectacle[id]; - if (zone) widgetByZone[zone] = id; - } - - const onPointerDown = (id: string, ev: ReactPointerEvent) => { - 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) => { - 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 ( -
- {widgetIds.map((id) => { - const zone = layout.spectacle[id]; - if (!zone) return null; - return ( -
onPointerDown(id, ev)} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - {widgets[id]} -
- ); - })} - {dragTarget && ( -
- )} -
- ); -}; - -// --- Hyprland Mode --- - -const HyprlandLayout = ({ widgets, layout }: Omit) => { - const onDoubleClick = (id: string, ev: ReactMouseEvent) => { - 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" },