This commit is contained in:
2026-02-19 12:03:45 +00:00
parent dd8ab84df5
commit 111d4c86eb
12 changed files with 174 additions and 183 deletions
@@ -1,3 +1,4 @@
import { Link, useLocation, useNavigate } from 'react-router';
import { LayoutGrid, Plus, Pencil, Trash2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
@@ -14,6 +15,8 @@ import {
} from './constants';
export const WorkspaceListApp = () => {
const location = useLocation();
const navigate = useNavigate();
const client = useClient();
const queryClient = useQueryClient();
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
@@ -24,6 +27,8 @@ export const WorkspaceListApp = () => {
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const isWorkspacesPage = location.pathname === '/workspaces';
const handleEdit = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
ev.stopPropagation();
setSelected(null);
@@ -39,7 +44,6 @@ export const WorkspaceListApp = () => {
setWorkspaces((prev) => prev.filter((w) => w.id !== ws.id));
if (selected === ws.id) setSelected(null);
// Clean up persisted layout
const layoutKey = `ws-layout-${ws.id}`;
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
const { [layoutKey]: _, ...rest } = currentState;
@@ -47,62 +51,77 @@ export const WorkspaceListApp = () => {
client.patch('/user/state', { [layoutKey]: null }).catch(() => {});
};
const handleClick = (ws: WorkspaceDefinition) => {
if (isWorkspacesPage) {
setSelected(ws.id);
setCreating(false);
setEditing(null);
} else {
navigate(`/workspaces/${ws.id}`);
}
};
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-0">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Link
to="/workspaces"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
>
<LayoutGrid className="h-4 w-4" />
Workspaces
</div>
</Link>
</div>
<div className="flex flex-col gap-0.5 px-3 pt-3">
{workspaces.map((ws) => (
<div
key={ws.id}
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
selected === ws.id
isWorkspacesPage && selected === ws.id
? 'bg-duck-teal/10 text-duck-teal'
: 'text-duck-dark/60 hover:bg-duck-dark/5'
}`}
onClick={() => {
setSelected(ws.id);
setCreating(false);
setEditing(null);
}}
onClick={() => handleClick(ws)}
>
<LayoutGrid className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">{ws.name}</span>
<button
type="button"
onClick={(ev) => handleEdit(ev, ws)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity cursor-pointer"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(ev) => handleDelete(ev, ws)}
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>
{isWorkspacesPage && (
<>
<button
type="button"
onClick={(ev) => handleEdit(ev, ws)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity cursor-pointer"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(ev) => handleDelete(ev, ws)}
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>
</>
)}
</div>
))}
{workspaces.length === 0 && (
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">No workspaces yet</p>
)}
<button
type="button"
onClick={() => {
setSelected(null);
setEditing(null);
setCreating(true);
}}
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all cursor-pointer"
>
<Plus className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left">New workspace</span>
</button>
{isWorkspacesPage && (
<button
type="button"
onClick={() => {
setSelected(null);
setEditing(null);
setCreating(true);
}}
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all cursor-pointer"
>
<Plus className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left">New workspace</span>
</button>
)}
</div>
</div>
);
@@ -6,9 +6,10 @@ import { appRegistry } from './app-registry';
export const WorkspaceScreen = () => {
const { id } = useParams<{ id: string }>();
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
const [workspaces, , isLoaded] = useUserState<WorkspaceDefinition[]>('workspaces', []);
const workspace = workspaces.find((ws) => ws.id === id);
if (!isLoaded) return null;
if (!workspace) return <Navigate to="/workspaces" replace />;
return <WorkspaceScreenInner workspace={workspace} />;
@@ -6,7 +6,7 @@ type AppPickerProps = {
};
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
const entries = Object.entries(registry);
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget);
return (
<div className="grid grid-cols-3 gap-2 max-w-xs">
@@ -1,88 +1,92 @@
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
import { Card } from '../Card';
import { AppPicker } from './AppPicker';
import { LayoutEditor } from './LayoutEditor';
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
} from '../ui/context-menu';
type PanelSlotProps = {
panel: LayoutPanel;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
interactive: boolean;
isLastPanel: boolean;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
};
export const PanelSlot = ({ panel, registry, components, editing, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
type PanelContextMenuProps = {
panelId: string;
hasApp: boolean;
isLastPanel: boolean;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
onClearApp: () => void;
children: React.ReactNode;
};
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
{!isLastPanel && (
<ContextMenuItem className="text-red-500 focus:text-red-500" onClick={() => onRemove(panelId)}>
Remove panel
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
const PanelComponent = components?.[panel.id];
const entry = panel.appType ? registry[panel.appType] : null;
const AppComponent = PanelComponent ?? entry?.component;
if (!editing && !AppComponent) {
return (
<div className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
</div>
);
}
const contextMenu = interactive
? (content: React.ReactNode) => (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
{content}
</PanelContextMenu>
)
: (content: React.ReactNode) => <>{content}</>;
if (!AppComponent) {
return (
if (!interactive) {
return (
<div className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
</div>
);
}
return contextMenu(
<div className="h-full w-full p-1">
<Card className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4">
<AppPicker registry={registry} onSelect={(type) => onSetApp(panel.id, type)} />
<div className="flex gap-1">
<button
type="button"
className="px-2 py-1 text-xs rounded border border-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>
</Card>
</div>
</div>,
);
}
if (entry?.transparent) {
return (
return contextMenu(
<div className="h-full w-full p-1">
<div className="relative h-full w-full overflow-hidden">
<AppComponent panelId={panel.id} />
{editing && (
<LayoutEditor
panelId={panel.id}
hasWidget
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)}
</div>
</div>
</div>,
);
}
return (
return contextMenu(
<div className="h-full w-full p-1">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
@@ -91,17 +95,7 @@ export const PanelSlot = ({ panel, registry, components, editing, isLastPanel, o
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-auto">
<AppComponent panelId={panel.id} />
</Card>
{editing && (
<LayoutEditor
panelId={panel.id}
hasWidget
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)}
</div>
</div>
</div>,
);
};
@@ -3,13 +3,11 @@ 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;
@@ -24,12 +24,11 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
);
return (
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', editing: false }}>
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~' }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
components={components}
editing={false}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
@@ -8,7 +8,7 @@ type WorkspaceRendererProps = {
layout: LayoutNode;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
interactive?: boolean;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
@@ -19,7 +19,7 @@ export const WorkspaceRenderer = ({
layout,
registry,
components,
editing,
interactive = false,
onSetApp,
onSplit,
onRemove,
@@ -33,7 +33,7 @@ export const WorkspaceRenderer = ({
node={layout}
registry={registry}
components={components}
editing={editing}
interactive={interactive}
totalPanels={totalPanels}
onSetApp={onSetApp}
onSplit={onSplit}
@@ -48,7 +48,7 @@ type LayoutNodeRendererProps = {
node: LayoutNode;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
interactive: boolean;
totalPanels: number;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
@@ -65,7 +65,7 @@ const LayoutNodeRenderer = ({
node,
registry,
components,
editing,
interactive,
totalPanels,
onSetApp,
onSplit,
@@ -96,7 +96,7 @@ const LayoutNodeRenderer = ({
panel={node}
registry={registry}
components={components}
editing={editing}
interactive={interactive}
isLastPanel={totalPanels <= 1}
onSetApp={onSetApp}
onSplit={onSplit}
@@ -118,7 +118,7 @@ const LayoutNodeRenderer = ({
node={child.node}
registry={registry}
components={components}
editing={editing}
interactive={interactive}
totalPanels={totalPanels}
onSetApp={onSetApp}
onSplit={onSplit}
@@ -133,16 +133,16 @@ const LayoutNodeRenderer = ({
}
return (
<ResizablePanelGroup direction={node.direction} onLayout={handleLayout} className="h-full w-full">
<ResizablePanelGroup id={node.id} 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}>
<ResizablePanel id={child.node.id} order={i} defaultSize={child.size} minSize={5}>
<div className="h-full w-full">
<LayoutNodeRenderer
node={child.node}
registry={registry}
components={components}
editing={editing}
interactive={interactive}
totalPanels={totalPanels}
onSetApp={onSetApp}
onSplit={onSplit}
@@ -1,21 +1,17 @@
import { useState, useCallback } from 'react';
import { useCallback } from 'react';
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
import { splitPanel, removePanel, setApp, updateSizes, countPanels, hasAnyApp } from './layout-utils';
import { splitPanel, removePanel, setApp, 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: AppRegistry;
};
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
const [editing, setEditing] = useState(() => !hasAnyApp(layout));
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
const handleSetApp = useCallback(
(panelId: string, appType: string | null) => {
onLayoutChange(setApp(layout, panelId, appType));
@@ -45,24 +41,17 @@ export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registr
[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}
onSetApp={handleSetApp}
onSplit={handleSplit}
onRemove={handleRemove}
onResized={handleResized}
/>
</div>
</div>
<WorkspaceProvider value={{ workspaceId: workspace?.id ?? null, cwd: workspace?.cwd ?? '~' }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
interactive
onSetApp={handleSetApp}
onSplit={handleSplit}
onRemove={handleRemove}
onResized={handleResized}
/>
</WorkspaceProvider>
);
};
@@ -10,15 +10,14 @@ export const createDefaultLayout = (): LayoutPanel => ({
});
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;
return splitInner(root, panelId, direction);
}
function splitInner(node: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
if (node.type === 'panel') {
if (node.id !== panelId) return node;
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
if (parent && parent.direction === direction) {
return null;
}
const group: LayoutGroup = {
type: 'group',
id: uid(),
@@ -29,41 +28,29 @@ export function splitPanel(root: LayoutNode, panelId: string, direction: 'horizo
],
};
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') {
// Check if the target panel is a direct child and the directions match — append as sibling
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === panelId);
if (childIdx !== -1 && node.direction === direction) {
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
const idx = parent.children.findIndex((c) => c.node.id === node.id);
const newChildren = [
...parent.children.slice(0, idx + 1),
...node.children.slice(0, childIdx + 1),
{ node: newPanel, size: 0 },
...parent.children.slice(idx + 1),
...node.children.slice(childIdx + 1),
];
const size = 100 / newChildren.length;
parent.children = newChildren.map((c) => ({ ...c, size }));
return node;
return { ...node, children: newChildren.map((c) => ({ ...c, size })) };
}
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 };
}
// Recurse into children
const newChildren = node.children.map((child) => ({
...child,
node: splitInner(child.node, panelId, direction),
}));
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
return { ...node, children: newChildren };
}
return node;
}
@@ -30,6 +30,7 @@ export type AppRegistryEntry = {
component: ComponentType<{ panelId: string }>;
transparent?: boolean;
fixedHeight?: number;
widget?: boolean;
};
export type AppRegistry = Record<string, AppRegistryEntry>;
+12 -9
View File
@@ -11,13 +11,14 @@ type WidgetProps = ComponentPropsWithoutRef<'div'> & {
title?: string;
resizable?: boolean;
collapsible?: boolean | { title: string; icon?: LucideIcon };
minimizable?: boolean;
moveable?: boolean;
position?: Position;
onPositionChange?: (pos: Position) => void;
onClose?: () => void;
};
export const Widget = ({ title, className, style, resizable, collapsible, moveable, position: controlledPosition, onPositionChange, onClose, children, ...props }: WidgetProps) => {
export const Widget = ({ title, className, style, resizable, collapsible, minimizable = false, moveable, position: controlledPosition, onPositionChange, onClose, children, ...props }: WidgetProps) => {
const [expanded, setExpanded] = useState(true);
const [minimized, setMinimized] = useState(false);
const [internalPosition, setInternalPosition] = useState<Position>({ x: 0, y: 0 });
@@ -69,7 +70,7 @@ export const Widget = ({ title, className, style, resizable, collapsible, moveab
const isCardPadding = target === card;
const isHeader = !isCardPadding && target.closest('[data-widget-header]') && !target.closest('button');
if (!isCardPadding && !isHeader) return;
if (isHeader && ev.detail === 2) {
if (minimizable && isHeader && ev.detail === 2) {
toggleMinimized();
return;
}
@@ -132,13 +133,15 @@ 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>}
<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>
{minimizable && (
<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"
+5 -5
View File
@@ -7,9 +7,9 @@ import { DailyGoals } from './DailyGoals/index';
import { QuickNotes } from './QuickNotes/index';
export const widgetRegistry: Record<string, AppRegistryEntry> = {
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock /> },
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather /> },
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro /> },
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals /> },
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes /> },
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock />, widget: true },
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather />, widget: true },
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro />, widget: true },
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals />, widget: true },
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes />, widget: true },
};