[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
450
components/ui/aside-panel.tsx
Normal file
450
components/ui/aside-panel.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
|
||||
export const DEFAULT_ASIDE_INLINE_WIDTH_PX = 380;
|
||||
const MIN_ASIDE_INLINE_WIDTH_PX = 320;
|
||||
const MAX_ASIDE_INLINE_WIDTH_PX = 720;
|
||||
|
||||
export function clampAsideInlineWidth(width: number): number {
|
||||
return Math.max(MIN_ASIDE_INLINE_WIDTH_PX, Math.min(MAX_ASIDE_INLINE_WIDTH_PX, width));
|
||||
}
|
||||
|
||||
export interface AsidePanelResizeProps {
|
||||
resizable?: boolean;
|
||||
persistWidthStorageKey?: string;
|
||||
resizeAriaLabel?: string;
|
||||
}
|
||||
|
||||
function parseInlineWidthPx(width: string): number {
|
||||
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
|
||||
if (arbitraryWidthMatch) {
|
||||
const raw = arbitraryWidthMatch[1].trim();
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isNaN(parsed)) return clampAsideInlineWidth(parsed);
|
||||
}
|
||||
|
||||
switch (width) {
|
||||
case 'w-full':
|
||||
case 'w-screen':
|
||||
return DEFAULT_ASIDE_INLINE_WIDTH_PX;
|
||||
default:
|
||||
return DEFAULT_ASIDE_INLINE_WIDTH_PX;
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedAsideWidth(storageKey: string | undefined, fallback: number): number {
|
||||
if (!storageKey) return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
return clampAsideInlineWidth(stored);
|
||||
}
|
||||
|
||||
// Types
|
||||
interface AsideContentItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface AsidePanelContextType {
|
||||
push: (item: AsideContentItem) => void;
|
||||
pop: () => void;
|
||||
replace: (item: AsideContentItem) => void;
|
||||
clear: () => void;
|
||||
canGoBack: boolean;
|
||||
currentItem: AsideContentItem | null;
|
||||
}
|
||||
|
||||
const AsidePanelContext = createContext<AsidePanelContextType | null>(null);
|
||||
const AsideActionMenuContext = createContext<(() => void) | null>(null);
|
||||
|
||||
export const useAsidePanel = () => {
|
||||
const context = useContext(AsidePanelContext);
|
||||
if (!context) {
|
||||
throw new Error('useAsidePanel must be used within an AsidePanel');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Props
|
||||
interface AsidePanelProps extends AsidePanelResizeProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
showBackButton?: boolean;
|
||||
onBack?: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
* Optional stable identifier emitted as `data-section` on the panel
|
||||
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
|
||||
*/
|
||||
dataSection?: string;
|
||||
}
|
||||
|
||||
interface AsidePanelHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
onBack?: () => void;
|
||||
onClose: () => void;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
// Header Component
|
||||
export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
onBack,
|
||||
onClose,
|
||||
showBackButton = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{showBackButton && onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold truncate">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{actions}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Content Component (wraps children with scroll)
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
// Footer Component
|
||||
export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: string }> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("px-4 py-3 border-t border-border/60 shrink-0", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Action Menu Component (for the ... button)
|
||||
interface AsideActionMenuProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="end">
|
||||
<AsideActionMenuContext.Provider value={close}>
|
||||
{children}
|
||||
</AsideActionMenuContext.Provider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const invokeAsideActionMenuItemClick = (
|
||||
closeMenu: (() => void) | null,
|
||||
onClick?: () => void,
|
||||
) => {
|
||||
closeMenu?.();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// Action Menu Item
|
||||
export const AsideActionMenuItem: React.FC<{
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: 'default' | 'destructive';
|
||||
}> = ({ icon, children, onClick, variant = 'default' }) => {
|
||||
const closeMenu = useContext(AsideActionMenuContext);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => invokeAsideActionMenuItemClick(closeMenu, onClick)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer",
|
||||
variant === 'destructive'
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Panel Component with Stack Support
|
||||
interface AsidePanelStackProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialItem: AsideContentItem;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
* Optional stable identifier emitted as `data-section` on the panel
|
||||
* root. Used as a targeting hook for Custom CSS.
|
||||
*/
|
||||
dataSection?: string;
|
||||
}
|
||||
|
||||
export type AsidePanelLayout = 'overlay' | 'inline';
|
||||
|
||||
const resolveInlineWidth = (width: string) => {
|
||||
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
|
||||
if (arbitraryWidthMatch) {
|
||||
return arbitraryWidthMatch[1];
|
||||
}
|
||||
|
||||
switch (width) {
|
||||
case 'w-full':
|
||||
return '100%';
|
||||
case 'w-screen':
|
||||
return '100vw';
|
||||
default:
|
||||
return '380px';
|
||||
}
|
||||
};
|
||||
|
||||
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
initialItem,
|
||||
className,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
dataSection,
|
||||
}) => {
|
||||
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
|
||||
|
||||
const push = useCallback((item: AsideContentItem) => {
|
||||
setStack(prev => [...prev, item]);
|
||||
}, []);
|
||||
|
||||
const pop = useCallback(() => {
|
||||
setStack(prev => {
|
||||
if (prev.length > 1) {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((item: AsideContentItem) => {
|
||||
setStack([item]);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setStack([initialItem]);
|
||||
}, [initialItem]);
|
||||
|
||||
const currentItem = stack[stack.length - 1];
|
||||
const canGoBack = stack.length > 1;
|
||||
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
|
||||
const inlineStyle = layout === 'inline'
|
||||
? ({
|
||||
width: inlineWidth,
|
||||
['--aside-inline-width' as string]: inlineWidth,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
// Reset stack when panel closes/opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setStack([initialItem]);
|
||||
}
|
||||
}, [open, initialItem]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
|
||||
<div className={cn(
|
||||
layout === 'inline'
|
||||
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && width,
|
||||
className
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}>
|
||||
<AsidePanelHeader
|
||||
title={currentItem.title}
|
||||
subtitle={currentItem.subtitle}
|
||||
actions={currentItem.actions}
|
||||
onBack={canGoBack ? pop : undefined}
|
||||
onClose={onClose}
|
||||
showBackButton={canGoBack}
|
||||
/>
|
||||
{currentItem.content}
|
||||
</div>
|
||||
</AsidePanelContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple Panel Component (no stack)
|
||||
export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
showBackButton,
|
||||
onBack,
|
||||
children,
|
||||
className,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
dataSection,
|
||||
}) => {
|
||||
const fallbackWidthPx = parseInlineWidthPx(width);
|
||||
const [panelWidthPx, setPanelWidthPx] = useState(() =>
|
||||
resizable ? readPersistedAsideWidth(persistWidthStorageKey, fallbackWidthPx) : fallbackWidthPx,
|
||||
);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const effectivePanelWidthPx = resizable ? panelWidthPx : fallbackWidthPx;
|
||||
// Resizable panels always use pixel width so overlay and inline share the same drag path.
|
||||
const usesPixelWidth = resizable || layout === 'inline';
|
||||
|
||||
const panelStyle = usesPixelWidth
|
||||
? ({
|
||||
width: `${effectivePanelWidthPx}px`,
|
||||
['--aside-inline-width' as string]: `${effectivePanelWidthPx}px`,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!resizable) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = panelWidthPx;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
|
||||
setIsResizing(true);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
setPanelWidthPx(clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
|
||||
};
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
const nextWidth = clampAsideInlineWidth(startWidth + startX - upEvent.clientX);
|
||||
setPanelWidthPx(nextWidth);
|
||||
if (persistWidthStorageKey) {
|
||||
localStorageAdapter.writeNumber(persistWidthStorageKey, nextWidth);
|
||||
}
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
}, [panelWidthPx, persistWidthStorageKey, resizable]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
layout === 'inline'
|
||||
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && !usesPixelWidth && width,
|
||||
isResizing && 'transition-none',
|
||||
className
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-section={dataSection}>
|
||||
{resizable ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={resizeAriaLabel}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-40 h-full w-2 -translate-x-1/2 cursor-col-resize',
|
||||
'after:absolute after:left-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:-translate-x-1/2 after:bg-border/0 after:transition-colors',
|
||||
'hover:after:bg-border/70',
|
||||
isResizing && 'after:bg-primary/70',
|
||||
)}
|
||||
onPointerDown={handleResizeStart}
|
||||
/>
|
||||
) : null}
|
||||
{title && (
|
||||
<AsidePanelHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
showBackButton={showBackButton}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AsidePanel;
|
||||
Reference in New Issue
Block a user