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
486 lines
18 KiB
TypeScript
486 lines
18 KiB
TypeScript
import React, { useMemo, useState } 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 { SelectGroupDialog } from './SelectGroupDialog';
|
|
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 { SnippetTargetsSection } from './snippets/SnippetTargetsSection';
|
|
import { ScriptEditorPanel } from './scripts/ScriptEditorPanel';
|
|
import { ScriptEditorModal } from './scripts/ScriptEditorModal';
|
|
import { isScriptSnippet } from '../domain/snippetScript.ts';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
|
import { Combobox } from './ui/combobox';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
|
import { HistoryItem } from './SnippetsHistoryItem';
|
|
import type { Snippet } from '@/domain/models';
|
|
import {
|
|
getRunnableHostsForSnippet,
|
|
resolveSnippetTargetGroupsForSave,
|
|
} from '@/domain/snippetTargets.ts';
|
|
import { STORAGE_KEY_SNIPPETS_PANEL_WIDTH } from '@/infrastructure/config/storageKeys.ts';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type SnippetsRightPanelProps = Record<string, any>;
|
|
|
|
export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
|
rightPanelMode,
|
|
hosts,
|
|
customGroups,
|
|
targetSelection,
|
|
setTargetSelection,
|
|
targetGroupSelection,
|
|
setTargetGroupSelection,
|
|
handleTargetSelect,
|
|
handleTargetSelectionChange,
|
|
handleTargetPickerBack,
|
|
availableKeys,
|
|
proxyProfiles,
|
|
managedSources,
|
|
onSaveHost,
|
|
onCreateGroup,
|
|
t,
|
|
handleClosePanel,
|
|
editingSnippet,
|
|
onDelete,
|
|
handleSave,
|
|
handleSaveAndRun,
|
|
setEditingSnippet,
|
|
packageOptions,
|
|
selectedPackage,
|
|
packages,
|
|
onPackagesChange,
|
|
shortkeyError,
|
|
setShortkeyError,
|
|
isRecordingShortkey,
|
|
setIsRecordingShortkey,
|
|
openTargetPicker,
|
|
targetHosts,
|
|
shellHistory,
|
|
handleHistoryScroll,
|
|
historyScrollRef,
|
|
visibleHistory,
|
|
saveHistoryAsSnippet,
|
|
handleCopy,
|
|
copiedId,
|
|
hasMoreHistory,
|
|
isLoadingMore,
|
|
loadMoreHistory,
|
|
onRunSnippet,
|
|
}) => {
|
|
const detectedVariables = useMemo(
|
|
() => parseSnippetVariables(editingSnippet?.command || ''),
|
|
[editingSnippet?.command],
|
|
);
|
|
const [scriptEditorModalOpen, setScriptEditorModalOpen] = useState(false);
|
|
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
|
|
const isEditingScript = isScriptSnippet(editingSnippet as import('../types').Snippet);
|
|
const snippetsPanelResizeProps = {
|
|
resizable: true as const,
|
|
persistWidthStorageKey: STORAGE_KEY_SNIPPETS_PANEL_WIDTH,
|
|
resizeAriaLabel: t('snippets.panel.resizeWidth'),
|
|
};
|
|
|
|
const runnableEditingSnippet = useMemo(() => ({
|
|
...(editingSnippet as Snippet),
|
|
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
|
targetGroups: resolveSnippetTargetGroupsForSave(
|
|
editingSnippet,
|
|
targetGroupSelection,
|
|
),
|
|
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
|
}), [editingSnippet, targetGroupSelection, targetSelection]);
|
|
|
|
const runTargets = useMemo(
|
|
() => getRunnableHostsForSnippet(runnableEditingSnippet, hosts),
|
|
[hosts, runnableEditingSnippet],
|
|
);
|
|
|
|
const runEditingScript = () => {
|
|
if (runTargets.length === 0) return;
|
|
onRunSnippet?.(runnableEditingSnippet, runTargets);
|
|
};
|
|
|
|
const canRunEditingScript = Boolean(editingSnippet.command?.trim()) && runTargets.length > 0;
|
|
|
|
const handleTargetsAllHostsChange = (checked: boolean) => {
|
|
if (checked) {
|
|
setTargetSelection([]);
|
|
setTargetGroupSelection([]);
|
|
setEditingSnippet({
|
|
...editingSnippet,
|
|
targetsAllHosts: true,
|
|
targets: [],
|
|
targetGroups: undefined,
|
|
});
|
|
return;
|
|
}
|
|
setEditingSnippet({
|
|
...editingSnippet,
|
|
targetsAllHosts: undefined,
|
|
});
|
|
};
|
|
|
|
if (rightPanelMode === 'select-targets') {
|
|
return (
|
|
<SelectHostPanel
|
|
hosts={hosts}
|
|
customGroups={customGroups}
|
|
selectedHostIds={targetSelection}
|
|
multiSelect={true}
|
|
onSelect={handleTargetSelect}
|
|
onSelectionChange={handleTargetSelectionChange}
|
|
onBack={handleTargetPickerBack}
|
|
onContinue={handleTargetPickerBack}
|
|
availableKeys={availableKeys}
|
|
proxyProfiles={proxyProfiles}
|
|
managedSources={managedSources}
|
|
onSaveHost={onSaveHost}
|
|
onCreateGroup={onCreateGroup}
|
|
title={t('snippets.targets.add')}
|
|
layout="inline"
|
|
{...snippetsPanelResizeProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (rightPanelMode === 'edit-snippet') {
|
|
return (
|
|
<>
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={handleClosePanel}
|
|
title={isEditingScript
|
|
? t(editingSnippet.id ? 'snippets.panel.editAutomationTitle' : 'snippets.panel.newAutomationTitle')
|
|
: t(editingSnippet.id ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
|
|
layout="inline"
|
|
{...snippetsPanelResizeProps}
|
|
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);
|
|
}}
|
|
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={handleSave}
|
|
disabled={!editingSnippet.label || !editingSnippet.command}
|
|
aria-label={t('common.save')}
|
|
>
|
|
<Check size={16} />
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<AsidePanelContent>
|
|
{!isEditingScript ? (
|
|
<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>
|
|
) : null}
|
|
|
|
{/* 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 / Snippet body */}
|
|
{isEditingScript ? (
|
|
<ScriptEditorPanel
|
|
snippet={editingSnippet as import('../types').Snippet}
|
|
onChange={setEditingSnippet}
|
|
canRun={canRunEditingScript}
|
|
onExpand={() => setScriptEditorModalOpen(true)}
|
|
onRun={canRunEditingScript ? runEditingScript : undefined}
|
|
/>
|
|
) : (
|
|
<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>
|
|
)}
|
|
|
|
<SnippetTargetsSection
|
|
t={t}
|
|
targetHosts={targetHosts}
|
|
targetGroups={isEditingScript ? targetGroupSelection : []}
|
|
onEditTargets={openTargetPicker}
|
|
onEditGroups={isEditingScript ? () => setGroupPickerOpen(true) : undefined}
|
|
hint={isEditingScript
|
|
? (editingSnippet.trigger === 'onConnect'
|
|
? t('scripts.targets.connectOrderHint')
|
|
: t('scripts.targets.hint'))
|
|
: undefined}
|
|
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
|
|
onTargetsAllHostsChange={handleTargetsAllHostsChange}
|
|
/>
|
|
|
|
{!isEditingScript ? (
|
|
<>
|
|
<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>
|
|
|
|
<Card className="p-3 space-y-2 bg-card border-border/80">
|
|
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.multiLineRunMode')}</p>
|
|
<Select
|
|
value={editingSnippet.multiLineRunMode ?? 'paste'}
|
|
onValueChange={(value) => setEditingSnippet({
|
|
...editingSnippet,
|
|
multiLineRunMode: value === 'lineDelay' ? 'lineDelay' : undefined,
|
|
})}
|
|
>
|
|
<SelectTrigger className="h-10">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="paste">{t('snippets.field.multiLineRunMode.paste')}</SelectItem>
|
|
<SelectItem value="lineDelay">{t('snippets.field.multiLineRunMode.lineDelay')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
|
{t('snippets.field.multiLineRunModeHint')}
|
|
</p>
|
|
</Card>
|
|
|
|
{/* 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>
|
|
</>
|
|
) : null}
|
|
</AsidePanelContent>
|
|
|
|
{/* Footer */}
|
|
<AsidePanelFooter>
|
|
<Button
|
|
className="w-full"
|
|
onClick={canRunEditingScript ? handleSaveAndRun : handleSave}
|
|
disabled={!editingSnippet.label || !editingSnippet.command}
|
|
>
|
|
{canRunEditingScript ? t('action.run') : t('common.save')}
|
|
</Button>
|
|
</AsidePanelFooter>
|
|
{isEditingScript ? (
|
|
<ScriptEditorModal
|
|
open={scriptEditorModalOpen}
|
|
onClose={() => setScriptEditorModalOpen(false)}
|
|
snippet={editingSnippet as import('../types').Snippet}
|
|
onChange={setEditingSnippet}
|
|
onSave={handleSave}
|
|
canRun={canRunEditingScript}
|
|
onRun={canRunEditingScript ? runEditingScript : undefined}
|
|
targetHosts={targetHosts}
|
|
hosts={hosts}
|
|
customGroups={customGroups}
|
|
selectedHostIds={targetSelection}
|
|
onSelectHost={handleTargetSelect}
|
|
onSelectionChange={handleTargetSelectionChange}
|
|
selectedGroupPaths={targetGroupSelection}
|
|
onGroupSelectionChange={setTargetGroupSelection}
|
|
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
|
|
onTargetsAllHostsChange={handleTargetsAllHostsChange}
|
|
/>
|
|
) : null}
|
|
</AsidePanel>
|
|
<SelectGroupDialog
|
|
open={groupPickerOpen}
|
|
onOpenChange={setGroupPickerOpen}
|
|
hosts={hosts}
|
|
customGroups={customGroups}
|
|
selectedGroupPaths={targetGroupSelection}
|
|
onSelectionChange={(nextGroups) => {
|
|
setTargetGroupSelection(nextGroups);
|
|
setEditingSnippet({
|
|
...editingSnippet,
|
|
targetGroups: nextGroups,
|
|
targetsAllHosts: undefined,
|
|
});
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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"
|
|
{...snippetsPanelResizeProps}
|
|
>
|
|
{/* 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;
|
|
};
|