/** * HistorySidePanel — command history browser for the terminal side panel. * * Two scopes: * - Host: remote shell history read from the focused session's history file. * - Global: commands recorded locally as the user types across all sessions. * * Uses VariableSizeVirtualList for performance with large lists (up to 1000 * entries). Long commands are truncated in the list; click a row to expand the * full text inline below that row. */ import { Clipboard as ClipboardIcon, FileCode, Globe, Play, RefreshCw, Search, Terminal as TerminalIcon, Trash2, } from 'lucide-react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; import { shouldRemoveAutocompleteHistoryEntry, toGlobalHistoryDisplayEntries, } from '../domain/globalHistory'; import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models'; import { removeCommandHistoryEntry } from './terminal/autocomplete/commandHistoryStore'; import { cn } from '../lib/utils'; import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState'; import { VariableSizeVirtualList, type VariableSizeVirtualListHandle, } from './ui/VariableSizeVirtualList'; import { Input } from './ui/input'; import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome'; export type HistoryPanelScope = 'host' | 'global'; export interface HistorySidePanelProps { focusedHost: Host | null; focusedSessionId: string | null; state: RemoteHistoryHostState; globalEntries: ShellHistoryEntry[]; onFetch: (sessionId: string, hostId: string) => void; onDeleteGlobalEntry?: (entryId: string) => void; /** Paste into the terminal without executing (no trailing Enter). */ onPasteToTerminal: (command: string) => void; /** Write to the terminal and execute (append Enter). */ onRunInTerminal: (command: string) => void; isVisible?: boolean; } const SUPPORTED_PROTOCOLS = new Set(['ssh', 'mosh', 'et']); const HISTORY_ROW_HEIGHT = 36; const HISTORY_ROW_WITH_HOST_HEIGHT = 46; const DETAIL_PADDING_Y = 12; const DETAIL_LINE_HEIGHT = 16; const DETAIL_MAX_COMMAND_LINES = 3; const DETAIL_TIMESTAMP_HEIGHT = 14; const DETAIL_HOST_LABEL_HEIGHT = 14; const DETAIL_ACTIONS_HEIGHT = 24; interface HistoryPanelEntry { id: string; command: string; hostId?: string; timestamp?: number; hostLabel?: string; } function getDetailRowHeight(entry: HistoryPanelEntry): number { const lineCount = Math.min( entry.command.split('\n').length, DETAIL_MAX_COMMAND_LINES, ); const commandHeight = Math.max(lineCount, 1) * DETAIL_LINE_HEIGHT; const timestampBlock = entry.timestamp ? DETAIL_TIMESTAMP_HEIGHT + 4 : 0; const hostLabelBlock = entry.hostLabel ? DETAIL_HOST_LABEL_HEIGHT + 2 : 0; return DETAIL_PADDING_Y + commandHeight + timestampBlock + hostLabelBlock + 4 + DETAIL_ACTIONS_HEIGHT; } type HistoryListRow = | { type: 'entry'; entry: HistoryPanelEntry } | { type: 'detail'; entry: HistoryPanelEntry }; function buildHistoryListRows( entries: HistoryPanelEntry[], selectedEntryId: string | null, ): HistoryListRow[] { const rows: HistoryListRow[] = []; for (const entry of entries) { rows.push({ type: 'entry', entry }); if (selectedEntryId === entry.id) { rows.push({ type: 'detail', entry }); } } return rows; } function remoteToPanelEntries(entries: RemoteHistoryEntry[]): HistoryPanelEntry[] { return entries.map((entry) => ({ id: entry.id, command: entry.command, timestamp: entry.timestamp, })); } const HistorySidePanelInner: React.FC = ({ focusedHost, focusedSessionId, state, globalEntries, onFetch, onDeleteGlobalEntry, onPasteToTerminal, onRunInTerminal, isVisible = true, }) => { const { t } = useI18n(); const [scope, setScope] = useState('host'); const [search, setSearch] = useState(''); const [selectedEntryId, setSelectedEntryId] = useState(null); const listRef = useRef(null); const protocol = focusedHost?.protocol; const isSupportedSession = !!focusedHost && !!focusedSessionId && SUPPORTED_PROTOCOLS.has(String(protocol ?? 'ssh')); useEffect(() => { if (!isVisible || scope !== 'host' || !isSupportedSession || !focusedHost || !focusedSessionId) { return; } if (state.loading) return; if (state.fetchedAt != null || state.error) return; onFetch(focusedSessionId, focusedHost.id); }, [ isVisible, scope, isSupportedSession, focusedHost, focusedSessionId, state.loading, state.fetchedAt, state.error, onFetch, ]); const handleRefresh = useCallback(() => { if (!focusedHost || !focusedSessionId) return; onFetch(focusedSessionId, focusedHost.id); }, [focusedHost, focusedSessionId, onFetch]); useEffect(() => { if (scope !== 'host') return; setSelectedEntryId(null); setSearch(''); }, [focusedHost?.id, scope]); useEffect(() => { setSelectedEntryId(null); }, [scope]); const sourceEntries = useMemo((): HistoryPanelEntry[] => { if (scope === 'global') { return toGlobalHistoryDisplayEntries(globalEntries); } return remoteToPanelEntries(state.entries); }, [scope, globalEntries, state.entries]); const filtered = useMemo((): HistoryPanelEntry[] => { if (!search.trim()) return sourceEntries; const q = search.toLowerCase(); return sourceEntries.filter( (entry) => entry.command.toLowerCase().includes(q) || entry.hostLabel?.toLowerCase().includes(q), ); }, [sourceEntries, search]); const listRows = useMemo( () => buildHistoryListRows(filtered, selectedEntryId), [filtered, selectedEntryId], ); const handleSaveAsSnippet = useCallback((entry: HistoryPanelEntry) => { window.dispatchEvent( new CustomEvent('netcatty:snippets:add', { detail: { command: entry.command }, }), ); }, []); const handleDeleteGlobalEntry = useCallback((entryId: string) => { if (scope !== 'global' || !onDeleteGlobalEntry) return; const entry = sourceEntries.find((candidate) => candidate.id === entryId); if (!entry) return; // Always remove the global row; only touch autocomplete when host is known. if ( entry.hostId && shouldRemoveAutocompleteHistoryEntry(globalEntries, entryId) ) { removeCommandHistoryEntry(entry.command, entry.hostId); } onDeleteGlobalEntry(entryId); setSelectedEntryId(null); }, [globalEntries, onDeleteGlobalEntry, scope, sourceEntries]); const handleRowClick = useCallback((entryId: string) => { setSelectedEntryId((current) => { const next = current === entryId ? null : entryId; if (next) { requestAnimationFrame(() => { const detailIndex = buildHistoryListRows(filtered, next).findIndex( (row) => row.type === 'detail' && row.entry.id === next, ); if (detailIndex >= 0) { listRef.current?.scrollToIndex(detailIndex, 'auto'); } }); } return next; }); }, [filtered]); const getRowHeight = useCallback( (row: HistoryListRow) => { if (row.type === 'detail') return getDetailRowHeight(row.entry); if (scope === 'global' && row.entry.hostLabel) return HISTORY_ROW_WITH_HOST_HEIGHT; return HISTORY_ROW_HEIGHT; }, [scope], ); const labels = useMemo( () => ({ paste: t('history.action.paste'), run: t('history.action.run'), save: t('history.action.saveAsSnippet'), delete: t('history.action.delete'), }), [t], ); const entryCount = sourceEntries.length; const showHostEmpty = scope === 'host' && !focusedHost; const showUnsupported = scope === 'host' && focusedHost && !isSupportedSession; const showLoading = scope === 'host' && focusedHost && isSupportedSession && state.loading && state.entries.length === 0; const showError = scope === 'host' && focusedHost && isSupportedSession && state.error; const showNoRemoteHistory = scope === 'host' && focusedHost && isSupportedSession && !state.loading && !state.error && state.entries.length === 0; const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0; if (!isVisible) return null; return (
setSearch(e.target.value)} placeholder={t('history.searchPlaceholder')} className="h-6 pl-7 text-xs bg-muted/30 border-none" />
{scope === 'host' && ( )}
} onClick={() => setScope('host')} className="max-w-[9rem]" /> } onClick={() => setScope('global')} />
{entryCount > 0 && ( {t('history.meta.count', { count: entryCount })} )}
{showHostEmpty && ( )} {showUnsupported && ( )} {showLoading && (
{t('history.loading')}
)} {showError && (
{state.error}
)} {showNoRemoteHistory && ( )} {showNoGlobalHistory && ( )} {filtered.length === 0 && sourceEntries.length > 0 && (
{t('common.noResultsFound')}
)} {listRows.length > 0 && ( row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`} renderItem={(row) => { if (row.type === 'detail') { return ( onRunInTerminal(row.entry.command)} onPaste={() => onPasteToTerminal(row.entry.command)} onSave={() => handleSaveAsSnippet(row.entry)} onDelete={ scope === 'global' && onDeleteGlobalEntry ? () => handleDeleteGlobalEntry(row.entry.id) : undefined } /> ); } return ( handleRowClick(row.entry.id)} onRun={() => onRunInTerminal(row.entry.command)} onPaste={() => onPasteToTerminal(row.entry.command)} onSave={() => handleSaveAsSnippet(row.entry)} onDelete={ scope === 'global' && onDeleteGlobalEntry ? () => handleDeleteGlobalEntry(row.entry.id) : undefined } /> ); }} /> )}
); }; const ScopeTab: React.FC<{ active: boolean; label: string; icon?: React.ReactNode; onClick: () => void; className?: string; }> = ({ active, label, icon, onClick, className }) => ( ); const EmptyState: React.FC<{ message: string }> = ({ message }) => (
{message}
); interface HistoryDetailStripProps { entry: HistoryPanelEntry; labels: { paste: string; run: string; save: string; delete: string }; onRun: () => void; onPaste: () => void; onSave: () => void; onDelete?: () => void; } const HistoryDetailStrip: React.FC = memo( ({ entry, labels, onRun, onPaste, onSave, onDelete }) => (
{entry.command}
{entry.hostLabel ? ( {entry.hostLabel} ) : null} {entry.timestamp ? ( {new Date(entry.timestamp).toLocaleString()} ) : null}
{onDelete ? ( ) : null}
), ); HistoryDetailStrip.displayName = 'HistoryDetailStrip'; interface HistoryRowProps { entry: HistoryPanelEntry; isSelected: boolean; showHostLabel: boolean; labels: { paste: string; run: string; save: string; delete: string }; onSelect: () => void; onRun: () => void; onPaste: () => void; onSave: () => void; onDelete?: () => void; } const HistoryRow: React.FC = memo( ({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave, onDelete }) => { const handleKeyDown = (event: React.KeyboardEvent) => { if (event.target !== event.currentTarget) return; if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); onSelect(); }; const handleMouseDown = (event: React.MouseEvent) => { if (event.detail > 1) { event.preventDefault(); } }; const rowTitle = isSelected ? undefined : [entry.command, showHostLabel && entry.hostLabel ? entry.hostLabel : null] .filter(Boolean) .join('\n'); return (
{entry.command}
{showHostLabel && entry.hostLabel ? (
{entry.hostLabel}
) : null}
event.stopPropagation()} > {onDelete ? ( ) : null}
); }, ); HistoryRow.displayName = 'HistoryRow'; const IconButton: React.FC<{ title: string; onClick: () => void; children: React.ReactNode; }> = ({ title, onClick, children }) => ( ); export const HistorySidePanel = memo(HistorySidePanelInner); HistorySidePanel.displayName = 'HistorySidePanel';