/** * 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, { 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 { ScrollArea } from '../ui/scroll-area'; 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__'; type Item = | { type: 'local'; id: typeof LOCAL_ITEM_ID } | { type: 'host'; id: string; host: Host }; 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); // 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 = useMemo(() => { const list: Item[] = []; if (localMatches) list.push({ type: 'local', id: LOCAL_ITEM_ID }); for (const h of filteredHosts) list.push({ type: 'host', id: h.id, host: h }); return list; }, [localMatches, filteredHosts]); const toggle = (id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; 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) => Math.min(i + 1, Math.max(items.length - 1, 0))); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); } else if (e.key === ' ' || (e.key === 'Enter' && !(e.metaKey || e.ctrlKey))) { if (items.length === 0) return; e.preventDefault(); toggle(items[selectedIndex].id); } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); handleCommit(); } }; if (!open) return null; const count = selected.size; const localIndex = items.findIndex((it) => it.type === 'local'); const firstHostIndex = items.findIndex((it) => it.type === 'host'); 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} )}
{/* Jump-to hint */}
Pick one or more Space toggle {typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'}+Enter add
{/* Local Shells section */} {localIndex !== -1 && (
Local Shells
{(() => { const idx = localIndex; const isCursor = idx === selectedIndex; const isChecked = selected.has(LOCAL_ITEM_ID); return (
toggle(LOCAL_ITEM_ID)} onMouseEnter={() => setSelectedIndex(idx)} >
Local Terminal {isChecked && }
); })()}
)} {/* Hosts section */} {filteredHosts.length > 0 && (
Hosts
{filteredHosts.map((host, i) => { const idx = firstHostIndex + i; const isCursor = idx === selectedIndex; const isChecked = selected.has(host.id); return (
toggle(host.id)} onMouseEnter={() => setSelectedIndex(idx)} >
{host.label || host.hostname}
{host.group ? `Personal / ${host.group}` : 'Personal'}
{isChecked && }
); })}
)} {items.length === 0 && (
No matches
)}
{/* Slim footer to commit. Kept minimal so the layout feels like QuickSwitcher's chrome with a single action strip tacked on. */}
); }; export default AddToWorkspaceDialog;