workspaces panel swap
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
|
import { ArrowLeftRight } from 'lucide-react';
|
||||||
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
|
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
|
||||||
|
import { useWorkspace } from './WorkspaceContext';
|
||||||
import { Card } from '../Card';
|
import { Card } from '../Card';
|
||||||
import { AppPicker } from './AppPicker';
|
import { AppPicker } from './AppPicker';
|
||||||
import {
|
import {
|
||||||
@@ -6,6 +8,7 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
} from '../ui/context-menu';
|
} from '../ui/context-menu';
|
||||||
|
|
||||||
type PanelSlotProps = {
|
type PanelSlotProps = {
|
||||||
@@ -29,21 +32,72 @@ type PanelContextMenuProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => (
|
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
|
||||||
<ContextMenu>
|
const { swapSourceId, setSwapSourceId } = useWorkspace();
|
||||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
return (
|
||||||
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
|
<ContextMenu>
|
||||||
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
|
<ContextMenuContent>
|
||||||
{!isLastPanel && (
|
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
|
||||||
<ContextMenuItem className="text-red-500 focus:text-red-500" onClick={() => onRemove(panelId)}>
|
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
|
||||||
Remove panel
|
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
|
||||||
</ContextMenuItem>
|
{!isLastPanel && (
|
||||||
)}
|
<ContextMenuItem className="text-red-500 focus:text-red-500" onClick={() => onRemove(panelId)}>
|
||||||
</ContextMenuContent>
|
Remove panel
|
||||||
</ContextMenu>
|
</ContextMenuItem>
|
||||||
);
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
{swapSourceId === panelId ? (
|
||||||
|
<ContextMenuItem onClick={() => setSwapSourceId(null)}>Cancel swap</ContextMenuItem>
|
||||||
|
) : swapSourceId ? (
|
||||||
|
<ContextMenuItem onClick={() => setSwapSourceId(null)}>Cancel swap</ContextMenuItem>
|
||||||
|
) : (
|
||||||
|
<ContextMenuItem onClick={() => setSwapSourceId(panelId)}>Swap with...</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SwapOverlay = ({ panelId }: { panelId: string }) => {
|
||||||
|
const { swapSourceId, setSwapSourceId, onSwap } = useWorkspace();
|
||||||
|
|
||||||
|
if (!swapSourceId || swapSourceId === panelId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-20 flex items-center justify-center bg-duck-teal/10 hover:bg-duck-teal/20 transition-colors cursor-pointer"
|
||||||
|
onClick={() => onSwap(swapSourceId, panelId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-background/90 backdrop-blur-sm px-3 py-2 shadow-lg border border-duck-teal/30">
|
||||||
|
<ArrowLeftRight className="h-4 w-4 text-duck-teal" />
|
||||||
|
<span className="text-sm font-medium text-duck-teal">Swap here</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
|
||||||
|
const { swapSourceId, setSwapSourceId } = useWorkspace();
|
||||||
|
|
||||||
|
if (swapSourceId !== panelId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-20 pointer-events-none ring-2 ring-inset ring-duck-teal/50 rounded-lg">
|
||||||
|
<div className="absolute top-2 left-2 pointer-events-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSwapSourceId(null)}
|
||||||
|
className="flex items-center gap-1.5 rounded-md bg-duck-teal/90 px-2.5 py-1 text-xs font-medium text-white shadow cursor-pointer hover:bg-duck-teal"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3 w-3" />
|
||||||
|
Swapping... click to cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||||
const PanelComponent = components?.[panel.id];
|
const PanelComponent = components?.[panel.id];
|
||||||
@@ -58,6 +112,15 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
|||||||
)
|
)
|
||||||
: (content: React.ReactNode) => <>{content}</>;
|
: (content: React.ReactNode) => <>{content}</>;
|
||||||
|
|
||||||
|
const swapOverlays = interactive
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<SwapOverlay panelId={panel.id} />
|
||||||
|
<SwapSourceIndicator panelId={panel.id} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!AppComponent) {
|
if (!AppComponent) {
|
||||||
if (!interactive) {
|
if (!interactive) {
|
||||||
return (
|
return (
|
||||||
@@ -68,26 +131,28 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
|||||||
}
|
}
|
||||||
|
|
||||||
return contextMenu(
|
return contextMenu(
|
||||||
<div className="h-full w-full p-1">
|
<div className="relative 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)} />
|
||||||
</Card>
|
</Card>
|
||||||
|
{swapOverlays}
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry?.transparent) {
|
if (entry?.transparent) {
|
||||||
return contextMenu(
|
return contextMenu(
|
||||||
<div className="h-full w-full p-1">
|
<div className="relative 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} />
|
||||||
</div>
|
</div>
|
||||||
|
{swapOverlays}
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return contextMenu(
|
return contextMenu(
|
||||||
<div className="h-full w-full p-1">
|
<div className="relative 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"
|
||||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||||
@@ -96,6 +161,7 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
|||||||
<AppComponent panelId={panel.id} />
|
<AppComponent panelId={panel.id} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
{swapOverlays}
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import { createContext, useContext } from 'react';
|
|||||||
type WorkspaceContextValue = {
|
type WorkspaceContextValue = {
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
swapSourceId: string | null;
|
||||||
|
setSwapSourceId: (id: string | null) => void;
|
||||||
|
onSwap: (sourceId: string, targetId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||||
workspaceId: null,
|
workspaceId: null,
|
||||||
cwd: '~',
|
cwd: '~',
|
||||||
|
swapSourceId: null,
|
||||||
|
setSwapSourceId: noop,
|
||||||
|
onSwap: noop,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkspaceProvider = WorkspaceContext.Provider;
|
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~' }}>
|
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop }}>
|
||||||
<WorkspaceRenderer
|
<WorkspaceRenderer
|
||||||
layout={layout}
|
layout={layout}
|
||||||
registry={registry}
|
registry={registry}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
||||||
import { splitPanel, removePanel, setApp, updateSizes, countPanels } from './layout-utils';
|
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, countPanels } from './layout-utils';
|
||||||
import { WorkspaceProvider } from './WorkspaceContext';
|
import { WorkspaceProvider } from './WorkspaceContext';
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ type WorkspaceViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||||
|
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
||||||
|
|
||||||
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));
|
||||||
@@ -41,8 +43,33 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
|
|||||||
[layout, onLayoutChange],
|
[layout, onLayoutChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSwap = useCallback(
|
||||||
|
(sourceId: string, targetId: string) => {
|
||||||
|
onLayoutChange(swapPanels(layout, sourceId, targetId));
|
||||||
|
setSwapSourceId(null);
|
||||||
|
},
|
||||||
|
[layout, onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!swapSourceId) return;
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === 'Escape') setSwapSourceId(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [swapSourceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceProvider value={{ workspaceId: workspace?.id ?? null, cwd: workspace?.cwd ?? '~' }}>
|
<WorkspaceProvider
|
||||||
|
value={{
|
||||||
|
workspaceId: workspace?.id ?? null,
|
||||||
|
cwd: workspace?.cwd ?? '~',
|
||||||
|
swapSourceId,
|
||||||
|
setSwapSourceId,
|
||||||
|
onSwap: handleSwap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<WorkspaceRenderer
|
<WorkspaceRenderer
|
||||||
layout={layout}
|
layout={layout}
|
||||||
registry={registry}
|
registry={registry}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents } from './types';
|
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents } from './types';
|
||||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||||
export { WorkspaceView } from './WorkspaceView';
|
export { WorkspaceView } from './WorkspaceView';
|
||||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||||
|
|||||||
@@ -139,6 +139,22 @@ export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function swapPanels(root: LayoutNode, idA: string, idB: string): LayoutNode {
|
||||||
|
const appA = findPanelApp(root, idA);
|
||||||
|
const appB = findPanelApp(root, idB);
|
||||||
|
if (appA === undefined || appB === undefined) return root;
|
||||||
|
return setApp(setApp(root, idA, appB), idB, appA);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPanelApp(node: LayoutNode, panelId: string): string | null | undefined {
|
||||||
|
if (node.type === 'panel') return node.id === panelId ? node.appType : undefined;
|
||||||
|
for (const child of node.children) {
|
||||||
|
const result = findPanelApp(child.node, panelId);
|
||||||
|
if (result !== undefined) return result;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function countPanels(node: LayoutNode): number {
|
export function countPanels(node: LayoutNode): number {
|
||||||
if (node.type === 'panel') return 1;
|
if (node.type === 'panel') return 1;
|
||||||
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user