[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:
604
components/HistorySidePanel.tsx
Normal file
604
components/HistorySidePanel.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* 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<HistorySidePanelProps> = ({
|
||||
focusedHost,
|
||||
focusedSessionId,
|
||||
state,
|
||||
globalEntries,
|
||||
onFetch,
|
||||
onDeleteGlobalEntry,
|
||||
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 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 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>
|
||||
)}
|
||||
</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>
|
||||
</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';
|
||||
Reference in New Issue
Block a user