Files
Netcatty/components/HistorySidePanel.tsx
T
Fyhax1kiandfyhax1ki d0e024625c feat: add clear-all command history action (#3632)
Add the Global history clear-all action with confirmation and five-language copy. Clear local shell history, autocomplete and compose recall while preserving drafts, keeping peer windows consistent on clears, and allowing new commands afterwards.

Verified with 116 focused checks, two clean independent final reviews, real Electron multi-window SSH-fixture interaction and restart, current-head Codex review, and both required CI jobs.

Co-authored-by: fyhax1ki <fyhax1ki@gmail.com>
2026-10-15 18:00:43 +08:00

639 lines
21 KiB
TypeScript

/**
* 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 '../application/state/commandHistoryStore';
import { cn } from '../lib/utils';
import { ConfirmDialog } from './ui/confirm-dialog';
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;
onClearGlobalHistory?: () => 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<HistorySidePanelProps> = ({
focusedHost,
focusedSessionId,
state,
globalEntries,
onFetch,
onDeleteGlobalEntry,
onClearGlobalHistory,
onPasteToTerminal,
onRunInTerminal,
isVisible = true,
}) => {
const { t } = useI18n();
const [scope, setScope] = useState<HistoryPanelScope>('host');
const [search, setSearch] = useState('');
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [clearAllOpen, setClearAllOpen] = useState(false);
const listRef = useRef<VariableSizeVirtualListHandle>(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 handleClearAll = useCallback(() => {
// The application-state action clears both the global shell history and the
// autocomplete history, and fans the clear out to every renderer.
onClearGlobalHistory?.();
setClearAllOpen(false);
setSelectedEntryId(null);
}, [onClearGlobalHistory]);
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 (
<div
className="h-full flex flex-col bg-background overflow-hidden"
data-section="history-panel"
data-history-scope={scope}
>
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
'px-2 border-b border-border/50 flex items-center gap-1.5',
)}>
<div className="relative flex-1 min-w-0">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('history.searchPlaceholder')}
className="h-6 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
{scope === 'host' && (
<button
type="button"
onClick={handleRefresh}
disabled={!isSupportedSession || state.loading}
title={t('history.action.refresh')}
aria-label={t('history.action.refresh')}
className="shrink-0 h-6 w-6 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
>
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
</button>
)}
{scope === 'global' && (
<button
type="button"
onClick={() => setClearAllOpen(true)}
disabled={!onClearGlobalHistory}
title={t('history.action.clearAll')}
aria-label={t('history.action.clearAll')}
className="shrink-0 h-6 w-6 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/30 min-h-[28px]">
<div
className="inline-flex max-w-[calc(100%-3.5rem)] items-center gap-0.5"
role="tablist"
aria-label={t('history.scope.label')}
>
<ScopeTab
active={scope === 'host'}
label={focusedHost?.label ?? t('history.tab.host')}
icon={<TerminalIcon size={10} className="shrink-0" />}
onClick={() => setScope('host')}
className="max-w-[9rem]"
/>
<ScopeTab
active={scope === 'global'}
label={t('history.tab.global')}
icon={<Globe size={10} className="shrink-0" />}
onClick={() => setScope('global')}
/>
</div>
{entryCount > 0 && (
<span className="ml-auto shrink-0 opacity-70">
{t('history.meta.count', { count: entryCount })}
</span>
)}
</div>
<div className="flex-1 min-h-0">
{showHostEmpty && (
<EmptyState message={t('history.empty.noSession')} />
)}
{showUnsupported && (
<EmptyState message={t('history.empty.unsupportedProtocol')} />
)}
{showLoading && (
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
<RefreshCw size={20} className="opacity-60 mb-2 animate-spin" />
<span className="text-xs">{t('history.loading')}</span>
</div>
)}
{showError && (
<div className="px-3 py-4 text-xs text-center">
<div className="text-destructive mb-2">{state.error}</div>
<button
type="button"
onClick={handleRefresh}
className="text-primary hover:underline"
>
{t('history.action.retry')}
</button>
</div>
)}
{showNoRemoteHistory && (
<EmptyState message={t('history.empty.noHistory')} />
)}
{showNoGlobalHistory && (
<EmptyState message={t('history.empty.noGlobalHistory')} />
)}
{filtered.length === 0 && sourceEntries.length > 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
)}
{listRows.length > 0 && (
<VariableSizeVirtualList
ref={listRef}
items={listRows}
getItemHeight={getRowHeight}
getItemKey={(row, index) =>
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
renderItem={(row) => {
if (row.type === 'detail') {
return (
<HistoryDetailStrip
entry={row.entry}
labels={labels}
onRun={() => onRunInTerminal(row.entry.command)}
onPaste={() => onPasteToTerminal(row.entry.command)}
onSave={() => handleSaveAsSnippet(row.entry)}
onDelete={
scope === 'global' && onDeleteGlobalEntry
? () => handleDeleteGlobalEntry(row.entry.id)
: undefined
}
/>
);
}
return (
<HistoryRow
entry={row.entry}
isSelected={selectedEntryId === row.entry.id}
showHostLabel={scope === 'global'}
labels={labels}
onSelect={() => 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
}
/>
);
}}
/>
)}
</div>
<ConfirmDialog
open={clearAllOpen}
onOpenChange={setClearAllOpen}
title={t('history.clearAll.dialog.title')}
message={t('history.clearAll.dialog.desc')}
confirmLabel={t('history.clearAll.dialog.confirm')}
destructive
onConfirm={handleClearAll}
/>
</div>
);
};
const ScopeTab: React.FC<{
active: boolean;
label: string;
icon?: React.ReactNode;
onClick: () => void;
className?: string;
}> = ({ active, label, icon, onClick, className }) => (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
title={label}
className={cn(
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] leading-4 transition-colors min-w-0 shrink whitespace-nowrap',
active
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
className,
)}
>
{icon}
<span className="truncate">{label}</span>
</button>
);
const EmptyState: React.FC<{ message: string }> = ({ message }) => (
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
<TerminalIcon size={24} className="opacity-40 mb-2" />
<span className="text-xs">{message}</span>
</div>
);
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<HistoryDetailStripProps> = memo(
({ entry, labels, onRun, onPaste, onSave, onDelete }) => (
<div
className="border-b border-border/40 bg-muted/20 px-3 py-1.5"
data-section="history-detail"
>
<div
className="font-mono text-[11px] leading-4 whitespace-pre-wrap break-words line-clamp-3 overflow-hidden"
style={{ overflowWrap: 'anywhere' }}
>
{entry.command}
</div>
<div className="flex items-center gap-1 mt-1 min-h-6">
<div className="flex-1 min-w-0">
{entry.hostLabel ? (
<span className="block text-[10px] text-muted-foreground truncate">
{entry.hostLabel}
</span>
) : null}
{entry.timestamp ? (
<span className="block text-[10px] text-muted-foreground truncate">
{new Date(entry.timestamp).toLocaleString()}
</span>
) : null}
</div>
<IconButton title={labels.run} onClick={onRun}>
<Play size={12} />
</IconButton>
<IconButton title={labels.paste} onClick={onPaste}>
<ClipboardIcon size={12} />
</IconButton>
<IconButton title={labels.save} onClick={onSave}>
<FileCode size={12} />
</IconButton>
{onDelete ? (
<IconButton title={labels.delete} onClick={onDelete}>
<Trash2 size={12} />
</IconButton>
) : null}
</div>
</div>
),
);
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<HistoryRowProps> = memo(
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave, onDelete }) => {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onSelect();
};
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.detail > 1) {
event.preventDefault();
}
};
const rowTitle = isSelected
? undefined
: [entry.command, showHostLabel && entry.hostLabel ? entry.hostLabel : null]
.filter(Boolean)
.join('\n');
return (
<div
className={cn(
'group flex select-none items-center gap-2 px-3 h-full hover:bg-accent/50 transition-colors cursor-pointer',
isSelected && 'bg-accent/30',
)}
role="button"
tabIndex={0}
aria-expanded={isSelected}
title={rowTitle}
onClick={onSelect}
onKeyDown={handleKeyDown}
onMouseDown={handleMouseDown}
>
<div className="w-0 flex-1 min-w-0">
<div className="font-mono text-[11px] truncate whitespace-nowrap">
{entry.command}
</div>
{showHostLabel && entry.hostLabel ? (
<div className="text-[10px] text-muted-foreground truncate">
{entry.hostLabel}
</div>
) : null}
</div>
<div
className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
onClick={(event) => event.stopPropagation()}
>
<IconButton title={labels.run} onClick={onRun}>
<Play size={12} />
</IconButton>
<IconButton title={labels.paste} onClick={onPaste}>
<ClipboardIcon size={12} />
</IconButton>
<IconButton title={labels.save} onClick={onSave}>
<FileCode size={12} />
</IconButton>
{onDelete ? (
<IconButton title={labels.delete} onClick={onDelete}>
<Trash2 size={12} />
</IconButton>
) : null}
</div>
</div>
);
},
);
HistoryRow.displayName = 'HistoryRow';
const IconButton: React.FC<{
title: string;
onClick: () => void;
children: React.ReactNode;
}> = ({ title, onClick, children }) => (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className="h-6 w-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/70 transition-colors"
>
{children}
</button>
);
export const HistorySidePanel = memo(HistorySidePanelInner);
HistorySidePanel.displayName = 'HistorySidePanel';