This commit is contained in:
2026-02-20 18:25:33 +00:00
parent ef1530b626
commit 25f5f74b1b
29 changed files with 1560 additions and 201 deletions
+119 -115
View File
@@ -20,6 +20,7 @@ export type TerminalViewProps = {
sandboxed?: boolean;
cwd?: string;
command?: string;
initialInput?: string;
fontSize?: number;
fontFamily?: string;
theme?: TerminalTheme;
@@ -37,7 +38,7 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
selectionBackground: '#3a3a5e',
};
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string) => {
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string, cols?: number, rows?: number) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
@@ -46,6 +47,8 @@ const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd
if (sandboxed === false) url += '&sandboxed=false';
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
if (command) url += `&command=${encodeURIComponent(command)}`;
if (cols) url += `&cols=${cols}`;
if (rows) url += `&rows=${rows}`;
return url;
};
@@ -57,6 +60,7 @@ export const TerminalView = ({
sandboxed = true,
cwd,
command,
initialInput,
fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme,
@@ -69,19 +73,20 @@ export const TerminalView = ({
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const isMounted = useMounted();
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
const commandRef = useRef(command);
const initialInputRef = useRef(initialInput);
onReadyRef.current = onReady;
onExitRef.current = onExit;
onCommandDoneRef.current = onCommandDone;
onDisconnectRef.current = onDisconnect;
commandRef.current = command;
initialInputRef.current = initialInput;
const background = theme?.background ?? DEFAULT_THEME.background;
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
@@ -94,135 +99,134 @@ export const TerminalView = ({
if (!container) return;
let disposed = false;
const initTimeout = setTimeout(() => {
if (disposed) return;
const term = new XTerm({
cursorBlink: true,
fontSize,
fontFamily,
theme: {
background,
foreground,
cursor,
selectionBackground,
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(container);
// Step 2: Create at 80x24, then fit after layout settles.
const term = new XTerm({
cursorBlink: true,
cols: 80,
rows: 24,
fontSize,
fontFamily,
theme: {
background,
foreground,
cursor,
selectionBackground,
},
});
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
if (viewport) {
viewport.style.scrollbarWidth = 'none';
viewport.style.overflow = 'hidden';
}
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(container);
if (autoFocus) term.focus();
fitAddon.fit();
if (autoFocus) term.focus();
termRef.current = term;
onReadyRef.current?.(term);
termRef.current = term;
fitAddonRef.current = fitAddon;
onReadyRef.current?.(term);
// Wait for layout to fully settle (double rAF), then fit + connect
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (disposed) return;
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command));
wsRef.current = ws;
let commandSent = false;
let commandDone = false;
let commandOutput = '';
const EXIT_MARKER = '__OFFICER_EXIT_';
// eslint-disable-next-line no-control-regex
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');
const handleOpen = () => {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
};
const handleMessage = (ev: MessageEvent) => {
try {
const msg = JSON.parse(ev.data as string);
if (msg.type === 'output') {
term.write(msg.data);
if (commandRef.current && !commandSent) {
commandSent = true;
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
const wrapped = onCommandDoneRef.current
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
: commandRef.current;
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
}
}, 100);
}
if (commandSent && !commandDone && onCommandDoneRef.current) {
commandOutput += msg.data as string;
const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/);
if (markerMatch) {
const exitCode = Number(markerMatch[1]);
const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
commandDone = true;
onCommandDoneRef.current(exitCode, output);
}
}
} else if (msg.type === 'exit') {
term.write('\r\n[Process exited]\r\n');
onExitRef.current?.();
} else if (msg.type === 'detached') {
term.write('\r\n[Session taken over]\r\n');
}
} catch {
// ignore
}
};
const handleClose = () => {
term.write('\r\n[Disconnected]\r\n');
onDisconnectRef.current?.();
};
ws.addEventListener('open', handleOpen);
ws.addEventListener('message', handleMessage);
ws.addEventListener('close', handleClose);
const dataDisposable = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
});
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
const cols = term.cols;
const rows = term.rows;
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command, cols, rows));
wsRef.current = ws;
let commandSent = false;
let commandDone = false;
let initialInputSent = false;
let commandOutput = '';
const EXIT_MARKER = '__OFFICER_EXIT_';
// eslint-disable-next-line no-control-regex
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');
const handleOpen = () => {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
};
const handleMessage = (ev: MessageEvent) => {
try {
const msg = JSON.parse(ev.data as string);
if (msg.type === 'output') {
term.write(msg.data);
if (commandRef.current && !commandSent) {
commandSent = true;
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
const wrapped = onCommandDoneRef.current
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
: commandRef.current;
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
}
}, 100);
}
if (!commandRef.current && initialInputRef.current && !initialInputSent) {
initialInputSent = true;
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data: initialInputRef.current + '\r' }));
}
}, 500);
}
if (commandSent && !commandDone && onCommandDoneRef.current) {
commandOutput += msg.data as string;
const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/);
if (markerMatch) {
const exitCode = Number(markerMatch[1]);
const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
commandDone = true;
onCommandDoneRef.current(exitCode, output);
}
}
} else if (msg.type === 'exit') {
term.write('\r\n[Process exited]\r\n');
onExitRef.current?.();
} else if (msg.type === 'detached') {
term.write('\r\n[Session taken over]\r\n');
}
} catch {
// ignore
}
};
const handleClose = () => {
term.write('\r\n[Disconnected]\r\n');
onDisconnectRef.current?.();
};
ws.addEventListener('open', handleOpen);
ws.addEventListener('message', handleMessage);
ws.addEventListener('close', handleClose);
const dataDisposable = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
});
(container as any).__terminalCleanup = () => {
dataDisposable.dispose();
ws.removeEventListener('open', handleOpen);
ws.removeEventListener('message', handleMessage);
ws.removeEventListener('close', handleClose);
ws.close();
};
});
resizeObserver.observe(container);
const cleanup = () => {
dataDisposable.dispose();
resizeObserver.disconnect();
ws.removeEventListener('open', handleOpen);
ws.removeEventListener('message', handleMessage);
ws.removeEventListener('close', handleClose);
};
(container as any).__terminalCleanup = cleanup;
}, 0);
});
return () => {
disposed = true;
clearTimeout(initTimeout);
const cleanup = (container as any).__terminalCleanup as (() => void) | undefined;
cleanup?.();
delete (container as any).__terminalCleanup;
wsRef.current?.close();
wsRef.current = null;
termRef.current?.dispose();
termRef.current = null;
fitAddonRef.current = null;
};
}, [
isMounted,
@@ -6,19 +6,19 @@ type AppPickerProps = {
};
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget);
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget && entry.availableOnPanel !== false);
return (
<div className="grid grid-cols-3 gap-2 max-w-xs">
<div className="flex flex-wrap gap-1.5 max-w-md">
{entries.map(([key, entry]) => (
<button
key={key}
type="button"
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
className="flex items-center gap-1.5 rounded-full border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-1.5 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
onClick={() => onSelect(key)}
>
<entry.icon className="h-5 w-5" />
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
<entry.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium whitespace-nowrap">{entry.name}</span>
</button>
))}
</div>
+142 -54
View File
@@ -1,5 +1,7 @@
import type { ComponentType } from 'react';
import { ArrowLeftRight, X } from 'lucide-react';
import { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { ArrowLeftRight, X, Minus } from 'lucide-react';
import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
import { useWorkspace } from './WorkspaceContext';
import { Card } from '../Card';
@@ -37,12 +39,17 @@ const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelCompone
typeof v === 'object' && v !== null && 'component' in v;
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
const { swapSourceId, setSwapSourceId } = useWorkspace();
const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
{isMaximized ? 'Restore' : 'Maximize'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
@@ -101,6 +108,61 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
);
};
const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId: string; isLastPanel: boolean; onRemove: (panelId: string) => void; onClearApp: () => void }) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
const handleClose = useCallback(() => {
onClearApp();
}, [onClearApp]);
const handleRestore = useCallback(() => {
setMaximizedPanelId(null);
}, [setMaximizedPanelId]);
const handleMaximize = useCallback(() => {
setMaximizedPanelId(panelId);
}, [panelId, setMaximizedPanelId]);
if (isMaximized) {
return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
type="button"
onClick={handleRestore}
className="group/btn h-3 w-3 rounded-full bg-[#febc2e] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title="Restore"
>
<Minus className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
</button>
</div>
);
}
return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
type="button"
onClick={handleClose}
className="group/btn h-3 w-3 rounded-full bg-[#ff5f57] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title={isLastPanel ? 'Clear app' : 'Close panel'}
>
<X className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
</button>
<button
type="button"
onClick={handleMaximize}
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title="Maximize"
>
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg>
</button>
</div>
);
};
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
// const DragHandle = ({ panelId }: { panelId: string }) => {
// const { setDragSourceId, dragSourceId } = useWorkspace();
@@ -120,6 +182,9 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
// };
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
const { maximizedPanelId, transitioningPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id;
const rawPanelComponent = components?.[panel.id];
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined);
@@ -144,9 +209,6 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
<>
<SwapOverlay panelId={panel.id} />
<SwapSourceIndicator panelId={panel.id} />
{/* TODO: re-enable when drag-to-reposition is polished */}
{/* <DragHandle panelId={panel.id} /> */}
{/* <DragSourceDimmer panelId={panel.id} /> */}
</>
) : null;
@@ -180,67 +242,93 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
);
}
// App with header — render header chrome + body
if (HeaderComponent) {
const headerBar = (
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
<HeaderComponent panelId={panel.id} />
{onClose && (
<button
onClick={onClose}
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
// All apps get a chrome header — custom HeaderComponent or default from registry icon+name
const DefaultHeader = entry ? () => (
<>
<entry.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
</>
) : null;
const body = (
<div className="flex-1 min-h-0">
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
<AppComponent panelId={panel.id} />
</Card>
</div>
);
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
const inner = ProviderComponent ? (
<ProviderComponent panelId={panel.id}>
{headerBar}
{body}
</ProviderComponent>
) : (
<>
{headerBar}
{body}
</>
);
const trafficLights = interactive ? (
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
) : null;
return contextMenu(
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
const headerContent = (
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
{onClose && (
<button
onClick={onClose}
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
>
{inner}
<X className="h-3.5 w-3.5" />
</button>
)}
{trafficLights}
</div>
);
const headerBar = interactive ? (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
{headerContent}
</PanelContextMenu>
) : headerContent;
const body = (
<div className="flex-1 min-h-0">
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
<AppComponent panelId={panel.id} />
</Card>
</div>
);
const inner = ProviderComponent ? (
<ProviderComponent panelId={panel.id}>
{headerBar}
{body}
</ProviderComponent>
) : (
<>
{headerBar}
{body}
</>
);
if (isMaximized) {
return (
<>
{/* Placeholder to preserve layout space */}
<div data-panel-id={panel.id} className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border border-dashed border-white/20" />
</div>
{overlays}
</div>,
{/* Maximized overlay — portalled to escape stacking contexts */}
{createPortal(
<div className="fixed inset-0 z-50 p-2">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl"
style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
>
{inner}
</div>
</div>,
document.body,
)}
</>
);
}
// Default: no header
return contextMenu(
return (
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
<div
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)' }}
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
>
<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>
{inner}
</div>
{overlays}
</div>,
</div>
);
};
@@ -11,6 +11,9 @@ type WorkspaceContextValue = {
dragSourceId: string | null;
setDragSourceId: (id: string | null) => void;
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
maximizedPanelId: string | null;
setMaximizedPanelId: (id: string | null) => void;
transitioningPanelId: string | null;
};
const noop = () => {};
@@ -24,6 +27,9 @@ const WorkspaceContext = createContext<WorkspaceContextValue>({
dragSourceId: null,
setDragSourceId: noop,
onMove: noop,
maximizedPanelId: null,
setMaximizedPanelId: noop,
transitioningPanelId: null,
});
export const WorkspaceProvider = WorkspaceContext.Provider;
@@ -24,7 +24,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
);
return (
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop }}>
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
@@ -1,11 +1,10 @@
import { useState, useCallback, useEffect } from 'react';
import { flushSync } from 'react-dom';
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
import type { DropPosition } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceRenderer } from './WorkspaceRenderer';
// TODO: re-enable when drag-to-reposition is polished
// import { DragOverlay } from './DragOverlay';
type WorkspaceViewProps = {
workspace: WorkspaceDefinition | null;
@@ -17,6 +16,22 @@ type WorkspaceViewProps = {
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
const [maximizedPanelId, setMaximizedPanelId] = useState<string | null>(null);
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
const setMaximizedAnimated = useCallback((id: string | null) => {
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
const panelId = maximizedPanelId ?? id;
if (doc.startViewTransition && panelId) {
setTransitioningPanelId(panelId);
requestAnimationFrame(() => {
const transition = doc.startViewTransition(() => flushSync(() => setMaximizedPanelId(id)));
transition.finished.finally(() => setTransitioningPanelId(null));
});
} else {
setMaximizedPanelId(id);
}
}, [maximizedPanelId]);
const handleSetApp = useCallback(
(panelId: string, appType: string | null) => {
@@ -72,16 +87,17 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
);
useEffect(() => {
if (!swapSourceId && !dragSourceId) return;
if (!swapSourceId && !dragSourceId && !maximizedPanelId) return;
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
setSwapSourceId(null);
setDragSourceId(null);
setMaximizedAnimated(null);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [swapSourceId, dragSourceId]);
}, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
return (
<WorkspaceProvider
@@ -94,6 +110,9 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
dragSourceId,
setDragSourceId: startDrag,
onMove: handleMove,
maximizedPanelId,
setMaximizedPanelId: setMaximizedAnimated,
transitioningPanelId,
}}
>
<WorkspaceRenderer
+1 -1
View File
@@ -1,4 +1,4 @@
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
export type { DropPosition } from './layout-utils';
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
@@ -24,6 +24,20 @@ export type WorkspaceDefinition = {
templateIdx?: number;
};
export type ProjectType = 'landing-page' | 'website' | 'app';
export type ProjectDefinition = {
id: string;
name: string;
cwd: string;
description?: string;
projectType: ProjectType;
hasBackend?: boolean;
hasAuth?: boolean;
gitRepo?: string;
templateIdx?: number;
};
export type AppRegistryEntry = {
name: string;
icon: LucideIcon;
@@ -33,6 +47,7 @@ export type AppRegistryEntry = {
transparent?: boolean;
fixedHeight?: number;
widget?: boolean;
availableOnPanel?: boolean;
};
export type AppRegistry = Record<string, AppRegistryEntry>;
+1 -1
View File
@@ -219,7 +219,7 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
/>
))}
<div className="absolute bottom-4 right-4 flex flex-col items-end">
<div className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}>
{showPicker && (
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
<WidgetPicker onSelect={addWidget} />