[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:
285
components/ui/toolbar-item-layout.tsx
Normal file
285
components/ui/toolbar-item-layout.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LayoutList,
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
PanelTop,
|
||||
} from 'lucide-react';
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
import type { ToolbarItemPlacement } from '../../domain/toolbarItemLayout';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from './button';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from './context-menu';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
|
||||
|
||||
const ToolbarOverflowCloseContext = createContext<(() => void) | null>(null);
|
||||
|
||||
/** Close the parent ⋮ overflow menu after a leaf action (no-op outside overflow). */
|
||||
export function useToolbarOverflowClose(): () => void {
|
||||
return useContext(ToolbarOverflowCloseContext) ?? (() => {});
|
||||
}
|
||||
|
||||
export type ToolbarCustomizeItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Icon shown before the item label in the customize menu. */
|
||||
icon?: React.ReactNode;
|
||||
/** When true, hide option is disabled (locked items). */
|
||||
locked?: boolean;
|
||||
/** When false, collapse is not offered (item is show/hide only). Default true. */
|
||||
supportsCollapse?: boolean;
|
||||
};
|
||||
|
||||
export type ToolbarCustomizeContextMenuProps = {
|
||||
items: ToolbarCustomizeItem[];
|
||||
placementOf: (id: string) => ToolbarItemPlacement;
|
||||
onSetPlacement: (id: string, placement: ToolbarItemPlacement) => unknown;
|
||||
onMove?: (id: string, direction: 'earlier' | 'later') => void;
|
||||
onReset: () => void;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
children: React.ReactNode;
|
||||
/** Optional className for the trigger wrapper. */
|
||||
className?: string;
|
||||
/** Optional inline style for the trigger wrapper (chrome theming). */
|
||||
style?: React.CSSProperties;
|
||||
/** Optional data-section marker for custom CSS / tests. */
|
||||
dataSection?: string;
|
||||
/** When false, right-click is disabled (e.g. compact mode). Default true. */
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const PLACEMENT_LABEL_KEYS: Record<ToolbarItemPlacement, string> = {
|
||||
show: 'toolbar.layout.show',
|
||||
collapse: 'toolbar.layout.collapse',
|
||||
hide: 'toolbar.layout.hide',
|
||||
};
|
||||
|
||||
const PLACEMENT_ICONS: Record<ToolbarItemPlacement, React.ReactNode> = {
|
||||
show: <Eye size={12} className="shrink-0 text-muted-foreground" />,
|
||||
collapse: <LayoutList size={12} className="shrink-0 text-muted-foreground" />,
|
||||
hide: <EyeOff size={12} className="shrink-0 text-muted-foreground" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Right-click the toolbar region to configure each action as show / collapse / hide.
|
||||
* Optional move earlier/later reorders the full item list.
|
||||
*/
|
||||
export const ToolbarCustomizeContextMenu: React.FC<ToolbarCustomizeContextMenuProps> = ({
|
||||
items,
|
||||
placementOf,
|
||||
onSetPlacement,
|
||||
onMove,
|
||||
onReset,
|
||||
t,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
dataSection,
|
||||
enabled = true,
|
||||
}) => {
|
||||
if (!enabled || items.length === 0) {
|
||||
return (
|
||||
<div className={className} style={style} data-section={dataSection}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
data-section={dataSection}
|
||||
data-toolbar-customize-root="true"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="min-w-[12rem]">
|
||||
<ContextMenuLabel className="flex items-center gap-2">
|
||||
<PanelTop size={14} className="shrink-0 text-muted-foreground" />
|
||||
{t('toolbar.layout.customize')}
|
||||
</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
{items.map((item, index) => {
|
||||
const placement = placementOf(item.id);
|
||||
return (
|
||||
<ContextMenuSub key={item.id}>
|
||||
<ContextMenuSubTrigger className="gap-2">
|
||||
{item.icon ? (
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-muted-foreground [&>svg]:h-3.5 [&>svg]:w-3.5">
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
|
||||
{PLACEMENT_ICONS[placement]}
|
||||
{t(PLACEMENT_LABEL_KEYS[placement])}
|
||||
</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[10rem]">
|
||||
<ContextMenuRadioGroup
|
||||
value={
|
||||
item.supportsCollapse === false && placement === 'collapse' ? 'show' : placement
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'show' || value === 'collapse' || value === 'hide') {
|
||||
if (value === 'collapse' && item.supportsCollapse === false) return;
|
||||
onSetPlacement(item.id, value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenuRadioItem value="show" className="gap-2">
|
||||
<Eye size={12} className="shrink-0" />
|
||||
{t('toolbar.layout.show')}
|
||||
</ContextMenuRadioItem>
|
||||
{item.supportsCollapse !== false && (
|
||||
<ContextMenuRadioItem value="collapse" className="gap-2">
|
||||
<LayoutList size={12} className="shrink-0" />
|
||||
{t('toolbar.layout.collapse')}
|
||||
</ContextMenuRadioItem>
|
||||
)}
|
||||
<ContextMenuRadioItem value="hide" disabled={item.locked} className="gap-2">
|
||||
<EyeOff size={12} className="shrink-0" />
|
||||
{t('toolbar.layout.hide')}
|
||||
</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
{onMove && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={index === 0}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onMove(item.id, 'earlier');
|
||||
}}
|
||||
>
|
||||
<ChevronUp size={14} className="mr-2" />
|
||||
{t('toolbar.layout.moveEarlier')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={index === items.length - 1}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onMove(item.id, 'later');
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={14} className="mr-2" />
|
||||
{t('toolbar.layout.moveLater')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
})}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => onReset()}>{t('toolbar.layout.reset')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolbarOverflowMenuProps = {
|
||||
/** When empty, the ⋮ trigger is not rendered. */
|
||||
hasItems: boolean;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
/** Icon orientation; terminal uses vertical, sftp horizontal. */
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
buttonClassName?: string;
|
||||
contentClassName?: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
/** Optional access to the persistent trigger, e.g. for restoring focus after a nested dialog. */
|
||||
triggerRef?: React.Ref<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* ⋮ button that opens the collapsed-item region. Hidden when nothing is collapsed.
|
||||
* Uses controlled Popover so leaf actions can close via useToolbarOverflowClose().
|
||||
* Nested portaled menus (encoding, bookmark list) use data-toolbar-nested-menu
|
||||
* to stay open while the nested panel is used.
|
||||
*/
|
||||
export const ToolbarOverflowMenu: React.FC<ToolbarOverflowMenuProps> = ({
|
||||
hasItems,
|
||||
label,
|
||||
children,
|
||||
orientation = 'horizontal',
|
||||
buttonClassName,
|
||||
contentClassName,
|
||||
align = 'end',
|
||||
triggerRef,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
if (!hasItems) return null;
|
||||
const Icon = orientation === 'vertical' ? MoreVertical : MoreHorizontal;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
aria-label={label}
|
||||
data-toolbar-overflow-trigger="true"
|
||||
>
|
||||
<Icon size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
className={cn('p-1 w-auto', contentClassName)}
|
||||
data-toolbar-overflow-menu="true"
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest('[data-toolbar-nested-menu="true"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Leaf clicks close; nested openers keep the menu for the child panel.
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
if (target.closest('[data-toolbar-overflow-keep-open="true"]')) return;
|
||||
if (target.closest('[data-toolbar-nested-menu="true"]')) return;
|
||||
if (target.closest('button, [role="menuitem"], a')) {
|
||||
// Defer so the leaf onClick still runs first.
|
||||
requestAnimationFrame(() => setOpen(false));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToolbarOverflowCloseContext.Provider value={close}>
|
||||
{children}
|
||||
</ToolbarOverflowCloseContext.Provider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user