361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { parseSnippetVariables } from '../domain/snippetVariables';
|
|
import { Check, Clock, Keyboard, Loader2, Package, RotateCcw, Trash2 } from 'lucide-react';
|
|
import { cn } from '../lib/utils';
|
|
import SelectHostPanel from './SelectHostPanel';
|
|
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
|
|
import { Button } from './ui/button';
|
|
import { Card } from './ui/card';
|
|
import { Input } from './ui/input';
|
|
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
|
import { Combobox } from './ui/combobox';
|
|
import { DistroAvatar } from './DistroAvatar';
|
|
import { HistoryItem } from './SnippetsHistoryItem';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type SnippetsRightPanelProps = Record<string, any>;
|
|
|
|
export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
|
rightPanelMode,
|
|
hosts,
|
|
customGroups,
|
|
targetSelection,
|
|
handleTargetSelect,
|
|
handleTargetPickerBack,
|
|
availableKeys,
|
|
proxyProfiles,
|
|
managedSources,
|
|
onSaveHost,
|
|
onCreateGroup,
|
|
t,
|
|
handleClosePanel,
|
|
editingSnippet,
|
|
onDelete,
|
|
handleSubmit,
|
|
setEditingSnippet,
|
|
packageOptions,
|
|
selectedPackage,
|
|
packages,
|
|
onPackagesChange,
|
|
shortkeyError,
|
|
setShortkeyError,
|
|
isRecordingShortkey,
|
|
setIsRecordingShortkey,
|
|
openTargetPicker,
|
|
targetHosts,
|
|
shellHistory,
|
|
handleHistoryScroll,
|
|
historyScrollRef,
|
|
visibleHistory,
|
|
saveHistoryAsSnippet,
|
|
handleCopy,
|
|
copiedId,
|
|
hasMoreHistory,
|
|
isLoadingMore,
|
|
loadMoreHistory,
|
|
}) => {
|
|
const detectedVariables = useMemo(
|
|
() => parseSnippetVariables(editingSnippet?.command || ''),
|
|
[editingSnippet?.command],
|
|
);
|
|
|
|
if (rightPanelMode === 'select-targets') {
|
|
return (
|
|
<SelectHostPanel
|
|
hosts={hosts}
|
|
customGroups={customGroups}
|
|
selectedHostIds={targetSelection}
|
|
multiSelect={true}
|
|
onSelect={handleTargetSelect}
|
|
onBack={handleTargetPickerBack}
|
|
onContinue={handleTargetPickerBack}
|
|
availableKeys={availableKeys}
|
|
proxyProfiles={proxyProfiles}
|
|
managedSources={managedSources}
|
|
onSaveHost={onSaveHost}
|
|
onCreateGroup={onCreateGroup}
|
|
title={t('snippets.targets.add')}
|
|
layout="inline"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (rightPanelMode === 'edit-snippet') {
|
|
return (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={handleClosePanel}
|
|
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
|
|
layout="inline"
|
|
actions={
|
|
<>
|
|
{editingSnippet.id && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
onClick={() => {
|
|
const id = editingSnippet.id;
|
|
if (!id) return;
|
|
onDelete(id);
|
|
handleClosePanel();
|
|
}}
|
|
aria-label={t('common.delete')}
|
|
>
|
|
<Trash2 size={16} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('common.delete')}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={handleSubmit}
|
|
disabled={!editingSnippet.label || !editingSnippet.command}
|
|
aria-label={t('common.save')}
|
|
>
|
|
<Check size={16} />
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<AsidePanelContent>
|
|
{/* Action Description */}
|
|
<Card className="p-3 space-y-2 bg-card border-border/80">
|
|
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.description')}</p>
|
|
<Input
|
|
placeholder={t('snippets.field.descriptionPlaceholder')}
|
|
value={editingSnippet.label || ''}
|
|
onChange={(e) => setEditingSnippet({ ...editingSnippet, label: e.target.value })}
|
|
className="h-10"
|
|
spellCheck={false}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Package */}
|
|
<Card className="p-3 space-y-2 bg-card border-border/80">
|
|
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.package')}</p>
|
|
<Combobox
|
|
options={packageOptions}
|
|
value={editingSnippet.package || selectedPackage || ''}
|
|
onValueChange={(val) => {
|
|
setEditingSnippet({ ...editingSnippet, package: val });
|
|
// If selecting an implicit parent path, persist it to packages
|
|
if (val && !packages.includes(val)) {
|
|
onPackagesChange([...packages, val]);
|
|
}
|
|
}}
|
|
placeholder={t('snippets.field.packagePlaceholder')}
|
|
allowCreate={true}
|
|
onCreateNew={(val) => {
|
|
if (!packages.includes(val)) {
|
|
onPackagesChange([...packages, val]);
|
|
}
|
|
}}
|
|
createText={t('snippets.field.createPackage')}
|
|
icon={<Package size={16} />}
|
|
triggerClassName="h-10"
|
|
/>
|
|
</Card>
|
|
|
|
{/* Script */}
|
|
<Card className="p-3 space-y-2 bg-card border-border/80">
|
|
<SnippetScriptEditor
|
|
label={t('snippets.field.scriptRequired')}
|
|
placeholder="ls -l"
|
|
value={editingSnippet.command || ''}
|
|
onChange={(command) => setEditingSnippet({ ...editingSnippet, command })}
|
|
/>
|
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
|
{t('snippets.field.variablesHelp')}
|
|
</p>
|
|
{detectedVariables.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
|
<span className="text-[10px] font-semibold text-muted-foreground shrink-0">
|
|
{t('snippets.field.variablesDetected')}:
|
|
</span>
|
|
{detectedVariables.map((variable) => (
|
|
<span
|
|
key={variable.name}
|
|
className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary font-mono"
|
|
>
|
|
{variable.name}
|
|
{variable.defaultValue !== undefined && (
|
|
<span className="text-muted-foreground font-sans ml-1">
|
|
({t('snippets.field.variableDefault', { value: variable.defaultValue })})
|
|
</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* No Auto Run */}
|
|
<label className="flex items-center gap-2 cursor-pointer px-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={editingSnippet.noAutoRun ?? false}
|
|
onChange={(e) => setEditingSnippet({ ...editingSnippet, noAutoRun: e.target.checked || undefined })}
|
|
className="rounded border-input"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
|
|
</label>
|
|
|
|
{/* Shortkey */}
|
|
<Card className="p-3 space-y-2 bg-card border-border/80">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
|
|
{editingSnippet.shortkey && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
onClick={() => {
|
|
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
|
setShortkeyError(null);
|
|
}}
|
|
>
|
|
<RotateCcw size={12} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('snippets.shortkey.clear')}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsRecordingShortkey(true);
|
|
setShortkeyError(null);
|
|
}}
|
|
className={cn(
|
|
"w-full h-10 px-3 text-sm font-mono rounded-lg border transition-colors flex items-center justify-center gap-2",
|
|
isRecordingShortkey
|
|
? "border-primary bg-primary/10 animate-pulse"
|
|
: "border-border hover:border-primary/50 bg-background"
|
|
)}
|
|
>
|
|
<Keyboard size={14} className="text-muted-foreground" />
|
|
{isRecordingShortkey
|
|
? t('snippets.shortkey.recording')
|
|
: editingSnippet.shortkey || t('snippets.shortkey.placeholder')}
|
|
</button>
|
|
{shortkeyError && (
|
|
<p className="text-xs text-destructive">{shortkeyError}</p>
|
|
)}
|
|
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
|
|
</Card>
|
|
|
|
{/* Targets */}
|
|
<Card className="p-3 space-y-3 bg-card border-border/80">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.targets.title')}</p>
|
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-primary" onClick={openTargetPicker}>
|
|
{t('action.edit')}
|
|
</Button>
|
|
</div>
|
|
|
|
{targetHosts.length === 0 ? (
|
|
<Button
|
|
variant="secondary"
|
|
className="w-full h-10"
|
|
onClick={openTargetPicker}
|
|
>
|
|
{t('snippets.targets.add')}
|
|
</Button>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{targetHosts.map((h) => (
|
|
<div key={h.id} className="flex items-center gap-3 px-3 py-2 bg-background/60 border border-border/70 rounded-lg">
|
|
<DistroAvatar host={h} fallback={h.os[0].toUpperCase()} className="h-10 w-10" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold truncate">{h.hostname}</div>
|
|
<div className="text-[11px] text-muted-foreground truncate">
|
|
{h.protocol || 'ssh'}, {h.username}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</AsidePanelContent>
|
|
|
|
{/* Footer */}
|
|
<AsidePanelFooter>
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleSubmit}
|
|
disabled={!editingSnippet.label || !editingSnippet.command}
|
|
>
|
|
{editingSnippet.targets?.length ? t('action.run') : t('common.save')}
|
|
</Button>
|
|
</AsidePanelFooter>
|
|
</AsidePanel>
|
|
);
|
|
}
|
|
|
|
if (rightPanelMode === 'history') {
|
|
return (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={handleClosePanel}
|
|
title={t('snippets.history.title')}
|
|
subtitle={t('snippets.history.subtitle', { count: shellHistory.length })}
|
|
showBackButton={true}
|
|
onBack={handleClosePanel}
|
|
layout="inline"
|
|
>
|
|
{/* History List */}
|
|
<div
|
|
className="flex-1 overflow-y-auto p-3 space-y-2"
|
|
onScroll={handleHistoryScroll}
|
|
ref={historyScrollRef}
|
|
>
|
|
{visibleHistory.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<Clock size={32} className="mx-auto mb-3 opacity-50" />
|
|
<p className="text-sm">{t('snippets.history.emptyTitle')}</p>
|
|
<p className="text-xs mt-1">{t('snippets.history.emptyDesc')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{visibleHistory.map((entry) => (
|
|
<HistoryItem
|
|
key={entry.id}
|
|
entry={entry}
|
|
onSaveAsSnippet={saveHistoryAsSnippet}
|
|
onCopy={() => handleCopy(entry.id, entry.command)}
|
|
isCopied={copiedId === entry.id}
|
|
/>
|
|
))}
|
|
{hasMoreHistory && (
|
|
<div className="py-4 text-center">
|
|
{isLoadingMore ? (
|
|
<Loader2 size={20} className="animate-spin mx-auto text-muted-foreground" />
|
|
) : (
|
|
<Button variant="ghost" size="sm" onClick={loadMoreHistory}>
|
|
{t('snippets.history.loadMore')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</AsidePanel>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|