/** * AddToWorkspaceDialog — lightweight multi-select picker for appending * new panes into the active workspace. Visually matches QuickSwitcher * (fixed top overlay, same header / row chrome) but with checkmarks on * the right and a thin footer to commit the selection. */ import { Check, Search, Terminal } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Host } from '../../types'; import { DistroAvatar } from '../DistroAvatar'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { VariableSizeVirtualList, type VariableSizeVirtualListHandle, } from '../ui/VariableSizeVirtualList'; import { clampListIndex, stepListIndex } from '../ui/virtualListMath'; export type AddTarget = | { kind: 'local' } | { kind: 'host'; host: Host }; interface AddToWorkspaceDialogProps { open: boolean; onOpenChange: (open: boolean) => void; hosts: Host[]; workspaceTitle?: string; onAdd: (targets: AddTarget[]) => void; } const LOCAL_ITEM_ID = '__local-terminal__'; const ADD_WS_ROW_HEIGHT = 44; const ADD_WS_HEADER_HEIGHT = 32; const ADD_WS_HINT_HEIGHT = 36; type Item = | { type: 'local'; id: typeof LOCAL_ITEM_ID } | { type: 'host'; id: string; host: Host }; type VisualRow = | { kind: 'hint'; key: string } | { kind: 'header'; key: string; label: string } | { kind: 'item'; key: string; item: Item; itemIndex: number }; export function shouldToggleWorkspaceTarget( key: string, query: string, metaKey: boolean, ctrlKey: boolean, ): boolean { if (metaKey || ctrlKey) return false; return key === 'Enter' || (key === ' ' && query.length === 0); } export function getWorkspaceTargetRowStateClass( isCursor: boolean, isChecked: boolean, ): string { if (isCursor && isChecked) return 'bg-primary/20'; if (isCursor) return 'bg-primary/15'; if (isChecked) return 'bg-primary/10'; return 'hover:bg-muted/50'; } export const AddToWorkspaceDialog: React.FC = ({ open, onOpenChange, hosts, workspaceTitle, onAdd, }) => { const [query, setQuery] = useState(''); const [selected, setSelected] = useState>(new Set()); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const containerRef = useRef(null); const listRef = useRef(null); // Reset on open + auto-focus the search input. useEffect(() => { if (!open) return; setQuery(''); setSelected(new Set()); setSelectedIndex(0); const timer = window.setTimeout(() => inputRef.current?.focus(), 40); return () => window.clearTimeout(timer); }, [open]); // Close on click outside. useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { onOpenChange(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open, onOpenChange]); // NOTE: no serial filter here — callers decide which subset of // hosts to pass based on mode. `appendHostToWorkspace` cannot build // a serial session, so append mode passes non-serial hosts only; // `createWorkspaceFromTargets` handles serial explicitly, so create // mode passes everything. const selectableHosts = hosts; const localMatches = useMemo(() => { const term = query.trim().toLowerCase(); if (!term) return true; return 'local terminal localhost'.includes(term); }, [query]); const filteredHosts = useMemo(() => { const term = query.trim().toLowerCase(); if (!term) return selectableHosts; return selectableHosts.filter((h) => (h.label?.toLowerCase().includes(term)) || (h.hostname?.toLowerCase().includes(term)) || (h.username?.toLowerCase().includes(term)) || (h.group?.toLowerCase().includes(term)), ); }, [selectableHosts, query]); const { items, visualRows, itemIndexToVisualIndex } = useMemo(() => { const list: Item[] = []; const visual: VisualRow[] = [{ kind: 'hint', key: 'hint' }]; const itemToVisual = new Map(); const pushHeader = (key: string, label: string) => { visual.push({ kind: 'header', key, label }); }; const pushItem = (item: Item) => { const itemIndex = list.length; list.push(item); itemToVisual.set(itemIndex, visual.length); visual.push({ kind: 'item', key: item.id, item, itemIndex }); }; if (localMatches) { pushHeader('header:local', 'Local Shells'); pushItem({ type: 'local', id: LOCAL_ITEM_ID }); } if (filteredHosts.length > 0) { pushHeader('header:hosts', 'Hosts'); for (const host of filteredHosts) { pushItem({ type: 'host', id: host.id, host }); } } return { items: list, visualRows: visual, itemIndexToVisualIndex: itemToVisual }; }, [filteredHosts, localMatches]); useEffect(() => { if (!open) return; setSelectedIndex((prev) => clampListIndex(prev, items.length)); }, [items.length, open]); useEffect(() => { if (!open) return; const visualIndex = itemIndexToVisualIndex.get(selectedIndex); if (visualIndex === undefined) return; listRef.current?.scrollToIndex(visualIndex, 'auto'); }, [itemIndexToVisualIndex, open, selectedIndex]); const toggle = useCallback((id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const handleTargetClick = useCallback((index: number, id: string) => { setSelectedIndex(index); toggle(id); inputRef.current?.focus(); }, [toggle]); const handleCommit = () => { if (selected.size === 0) return; const targets: AddTarget[] = []; if (selected.has(LOCAL_ITEM_ID)) targets.push({ kind: 'local' }); for (const host of selectableHosts) { if (selected.has(host.id)) targets.push({ kind: 'host', host }); } if (targets.length === 0) return; onAdd(targets); onOpenChange(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onOpenChange(false); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((i) => stepListIndex(i, items.length, 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => stepListIndex(i, items.length, -1)); } else if (shouldToggleWorkspaceTarget(e.key, query, e.metaKey, e.ctrlKey)) { if (items.length === 0) return; e.preventDefault(); const item = items[clampListIndex(selectedIndex, items.length)]; if (!item) return; toggle(item.id); } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); handleCommit(); } }; const getRowHeight = useCallback((row: VisualRow) => { if (row.kind === 'hint') return ADD_WS_HINT_HEIGHT; if (row.kind === 'header') return ADD_WS_HEADER_HEIGHT; return ADD_WS_ROW_HEIGHT; }, []); const renderRow = useCallback((row: VisualRow) => { if (row.kind === 'hint') { return (
Pick one or more Space / Enter toggle {typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'}+Enter add
); } if (row.kind === 'header') { return (
{row.label}
); } const { item, itemIndex: idx } = row; const isCursor = idx === selectedIndex; const isChecked = selected.has(item.id); if (item.type === 'local') { return (
handleTargetClick(idx, LOCAL_ITEM_ID)} >
Local Terminal {isChecked && }
); } const host = item.host; return (
handleTargetClick(idx, host.id)} >
{host.label || host.hostname}
{host.group ? `Personal / ${host.group}` : 'Personal'}
{isChecked && }
); }, [handleTargetClick, selected, selectedIndex]); if (!open) return null; const count = selected.size; return (
{/* Search header — mirrors QuickSwitcher chrome. */}
{ setQuery(e.target.value); setSelectedIndex(0); }} onKeyDown={handleKeyDown} placeholder="Search hosts or local shells..." className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm" /> {workspaceTitle && ( {workspaceTitle} )}
{/* Same max-h flex trap as Quick Switcher: list must not grow with the virtual spacer or hosts below the first screen become unreachable. */}
{items.length === 0 ? (
No matches
) : ( ref={listRef} items={visualRows} getItemHeight={getRowHeight} className="h-full max-h-[min(360px,calc(100vh-14rem))]" overscan={8} getItemKey={(row) => row.key} renderItem={renderRow} /> )}
{/* Slim footer to commit. Kept minimal so the layout feels like QuickSwitcher's chrome with a single action strip tacked on. */}
); }; export default AddToWorkspaceDialog;