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