[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:
42
components/AIChatPanelContent.test.ts
Normal file
42
components/AIChatPanelContent.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const panelSource = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
||||
const selectorSource = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
|
||||
const preparingSource = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
|
||||
const primaryToolbarSources = [
|
||||
panelSource,
|
||||
readFileSync(new URL('./ScriptsSidePanel.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./HistorySidePanel.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./SftpSidePanel.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./notes/NotesManager.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./systemManager/SystemManagerSidePanel.tsx', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./terminal/ThemeSidePanel.tsx', import.meta.url), 'utf8'),
|
||||
];
|
||||
|
||||
test('AI chat header matches the compact system panel toolbar height', () => {
|
||||
assert.match(panelSource, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
assert.match(selectorSource, /className="group flex h-6/);
|
||||
assert.match(selectorSource, /truncate text-\[11px\]/);
|
||||
assert.match(panelSource, /className="h-6 w-6 rounded-md/);
|
||||
});
|
||||
|
||||
test('AI chat preparing state keeps the same compact header height', () => {
|
||||
assert.match(preparingSource, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
assert.match(preparingSource, /className="h-6 w-32/);
|
||||
});
|
||||
|
||||
test('agent selector menu stays compact with restrained corners', () => {
|
||||
assert.match(selectorSource, /w-\[256px\].*rounded-md/);
|
||||
assert.match(selectorSource, /flex h-9 w-full items-center gap-2\.5 px-3/);
|
||||
assert.doesNotMatch(selectorSource, /rounded-lg/);
|
||||
assert.doesNotMatch(selectorSource, /rounded-2xl/);
|
||||
});
|
||||
|
||||
test('terminal side panel tools share one primary toolbar height', () => {
|
||||
for (const source of primaryToolbarSources) {
|
||||
assert.match(source, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
}
|
||||
});
|
||||
386
components/AIChatPanelContent.tsx
Normal file
386
components/AIChatPanelContent.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import React, { type Dispatch, type SetStateAction } from 'react';
|
||||
import { History, Plus } from 'lucide-react';
|
||||
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
|
||||
import type { Host, VaultNote } from '../types';
|
||||
import type { UserSkillOption } from './ai/userSkillsState';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
||||
import {
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
} from './ai/aiPanelDiagnostics';
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
type ExportFormat = 'md' | 'json' | 'txt';
|
||||
type TerminalSessionSummary = {
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
interface AIChatPanelContentProps {
|
||||
t: Translate;
|
||||
currentAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
discoveredAgents: DiscoveredAgent[];
|
||||
isDiscovering: boolean;
|
||||
handleAgentChange: (agentId: string) => void;
|
||||
handleEnableDiscoveredAgent: (agent: DiscoveredAgent) => void;
|
||||
rediscover: () => void;
|
||||
handleOpenSettings: () => void;
|
||||
activeSession: AISession | null;
|
||||
handleExport: (format: ExportFormat) => void;
|
||||
showHistory: boolean;
|
||||
setShowHistory: Dispatch<SetStateAction<boolean>>;
|
||||
handleNewChat: () => void;
|
||||
historySessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
handleSelectSession: (sessionId: string) => void;
|
||||
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleCompact: () => void;
|
||||
canCompact?: boolean;
|
||||
handleSteer: () => void;
|
||||
handleStop: () => void;
|
||||
canSteer: boolean;
|
||||
isSteering: boolean;
|
||||
steerWarning?: string;
|
||||
lockTurnConfiguration: boolean;
|
||||
canSendCurrentAgent: boolean;
|
||||
providerDisplayName?: string;
|
||||
modelDisplayName?: string;
|
||||
modelCatalogWarning?: string;
|
||||
agentModelPresets: AgentModelPreset[];
|
||||
selectedAgentModel: string;
|
||||
handleAgentModelSelect: (modelId: string) => void;
|
||||
cattyConfiguredProviders: ProviderConfig[];
|
||||
effectiveActiveProvider?: ProviderConfig;
|
||||
effectiveActiveModelId?: string;
|
||||
handleAgentProviderModelSelect: (providerId: string, modelId: string, contextWindow?: number) => void;
|
||||
selectedCattyThinking?: string;
|
||||
handleCattyThinkingSelect?: (level: string) => void;
|
||||
files: UploadedFile[];
|
||||
addFiles: (inputFiles: File[]) => Promise<void>;
|
||||
removeFile: (fileId: string) => void;
|
||||
terminalSessions: TerminalSessionSummary[];
|
||||
selectedUserSkills: UserSkillOption[];
|
||||
userSkillOptions: UserSkillOption[];
|
||||
quickMessages: AIQuickMessage[];
|
||||
addSelectedUserSkill: (slug: string) => void;
|
||||
removeSelectedUserSkill: (slug: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
notes?: VaultNote[];
|
||||
hosts?: Host[];
|
||||
/** Mention Note: attach a Vault → Notes entry as inline context. */
|
||||
onMentionNote?: (note: VaultNote) => void;
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
|
||||
/** Hidden retained panels keep the composer warm without the message tree. */
|
||||
parked?: boolean;
|
||||
/** Disable header transitions while send preflight is in flight. */
|
||||
sending?: boolean;
|
||||
}
|
||||
|
||||
export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
t,
|
||||
currentAgentId,
|
||||
externalAgents,
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
handleAgentChange,
|
||||
handleEnableDiscoveredAgent,
|
||||
rediscover,
|
||||
handleOpenSettings,
|
||||
activeSession,
|
||||
handleExport,
|
||||
showHistory,
|
||||
setShowHistory,
|
||||
handleNewChat,
|
||||
historySessions,
|
||||
activeSessionId,
|
||||
handleSelectSession,
|
||||
handleDeleteSession,
|
||||
messages,
|
||||
isStreaming,
|
||||
activeCompaction = null,
|
||||
contextUsage = null,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleCompact,
|
||||
canCompact = false,
|
||||
handleSteer,
|
||||
handleStop,
|
||||
canSteer,
|
||||
isSteering,
|
||||
steerWarning,
|
||||
lockTurnConfiguration,
|
||||
canSendCurrentAgent,
|
||||
providerDisplayName,
|
||||
modelDisplayName,
|
||||
modelCatalogWarning,
|
||||
agentModelPresets,
|
||||
selectedAgentModel,
|
||||
handleAgentModelSelect,
|
||||
cattyConfiguredProviders,
|
||||
effectiveActiveProvider,
|
||||
effectiveActiveModelId,
|
||||
handleAgentProviderModelSelect,
|
||||
selectedCattyThinking,
|
||||
handleCattyThinkingSelect,
|
||||
files,
|
||||
addFiles,
|
||||
removeFile,
|
||||
terminalSessions,
|
||||
selectedUserSkills,
|
||||
userSkillOptions,
|
||||
quickMessages,
|
||||
addSelectedUserSkill,
|
||||
removeSelectedUserSkill,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
notes = [],
|
||||
hosts = [],
|
||||
onMentionNote,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSection,
|
||||
parked = false,
|
||||
sending = false,
|
||||
}) => {
|
||||
const mentionHosts = React.useMemo(
|
||||
() => terminalSessions.map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
hostname: session.hostname,
|
||||
label: session.label,
|
||||
connected: session.connected,
|
||||
})),
|
||||
[terminalSessions],
|
||||
);
|
||||
const providerSwitcher = React.useMemo(
|
||||
() => (
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
),
|
||||
[
|
||||
cattyConfiguredProviders,
|
||||
currentAgentId,
|
||||
effectiveActiveModelId,
|
||||
effectiveActiveProvider?.id,
|
||||
handleAgentProviderModelSelect,
|
||||
],
|
||||
);
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
const hideHeader = isAIPanelDiagnosticPartHidden('header', hiddenParts);
|
||||
const hideHistory = isAIPanelDiagnosticPartHidden('history', hiddenParts);
|
||||
const hideMessages = isAIPanelDiagnosticPartHidden('messages', hiddenParts);
|
||||
const hideRecent = isAIPanelDiagnosticPartHidden('recent', hiddenParts);
|
||||
const hideInput = isAIPanelDiagnosticPartHidden('input', hiddenParts);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
||||
{/* ── Header ── */}
|
||||
{!hideHeader && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Header')}>
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
'px-2 flex items-center justify-between border-b border-border/50',
|
||||
)}>
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
parked={parked}
|
||||
disabled={sending}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground [&_svg]:size-3"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
disabled={sending}
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
disabled={sending}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{!parked && showHistory && !hideHistory ? (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.History')}>
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={sending ? () => undefined : handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
</React.Profiler>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
{!parked && !hideMessages && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Messages')}>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
activeCompaction={activeCompaction}
|
||||
notes={notes}
|
||||
hosts={hosts}
|
||||
onOpenVaultNote={onOpenVaultNote}
|
||||
onOpenVaultHost={onOpenVaultHost}
|
||||
onOpenVaultSection={onOpenVaultSection}
|
||||
/>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{!parked && messages.length === 0 && historySessions.length > 0 && !hideRecent && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Recent')}>
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
disabled={sending}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
{!hideInput && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
|
||||
<div>
|
||||
{steerWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{steerWarning}
|
||||
</div>
|
||||
) : modelCatalogWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{modelCatalogWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<ChatInput
|
||||
parked={parked}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onCompact={handleCompact}
|
||||
canCompact={canCompact}
|
||||
onSteer={handleSteer}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
canSteer={canSteer}
|
||||
isSteering={isSteering}
|
||||
lockTurnConfiguration={lockTurnConfiguration}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={providerSwitcher}
|
||||
pickerScope={currentAgentId}
|
||||
thinkingLevel={currentAgentId === 'catty' ? selectedCattyThinking : undefined}
|
||||
onThinkingLevelChange={currentAgentId === 'catty' ? handleCattyThinkingSelect : undefined}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={mentionHosts}
|
||||
notes={notes}
|
||||
onMentionNote={onMentionNote}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
contextUsage={contextUsage}
|
||||
/>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
135
components/AIChatSessionHistoryDrawer.tsx
Normal file
135
components/AIChatSessionHistoryDrawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Session History Drawer
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface SessionHistoryDrawerProps {
|
||||
sessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SESSION_RENDER_BATCH = 80;
|
||||
const SESSION_RENDER_STEP = 60;
|
||||
|
||||
export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
|
||||
|
||||
useEffect(() => {
|
||||
setRenderCount(SESSION_RENDER_BATCH);
|
||||
}, [sessions]);
|
||||
|
||||
const displayedSessions = sessions.slice(0, renderCount);
|
||||
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hiddenSessionCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderCount((count) => count + SESSION_RENDER_STEP)}
|
||||
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.loadMoreSessions').replace('{n}', String(hiddenSessionCount))}
|
||||
</button>
|
||||
)}
|
||||
{displayedSessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const time = new Date(session.updatedAt);
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
|
||||
className={cn(
|
||||
SESSION_HISTORY_ROW_CLASSNAMES.row,
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function formatRelativeTime(date: Date, t: (key: string) => string): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return t('ai.chat.justNow');
|
||||
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
|
||||
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
|
||||
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
418
components/AIChatSidePanel.mountRetention.test.ts
Normal file
418
components/AIChatSidePanel.mountRetention.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider.tsx';
|
||||
import type { AIDraft, AISession } from '../infrastructure/ai/types';
|
||||
import { TooltipProvider } from './ui/tooltip.tsx';
|
||||
import {
|
||||
aiChatSidePanelPropsAreEqual,
|
||||
AIChatSidePanel,
|
||||
hasAIChatSidePanelRetainedContent,
|
||||
shouldKeepAIChatSidePanelMounted,
|
||||
} from './AIChatSidePanel.tsx';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types.ts';
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
function installLocalStorage(): LocalStorageMock {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage: LocalStorageMock = {
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const draft = (overrides: Partial<AIDraft> = {}): AIDraft => ({
|
||||
text: '',
|
||||
agentId: 'catty',
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const session = (overrides: Partial<AISession> = {}): AISession => ({
|
||||
id: 'session-1',
|
||||
title: 'Session',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePanelProps => ({
|
||||
sessions: [],
|
||||
activeSessionIdMap: {},
|
||||
draftsByScope: {},
|
||||
panelViewByScope: {},
|
||||
setActiveSessionId: () => undefined,
|
||||
ensureDraftForScope: () => undefined,
|
||||
updateDraft: () => undefined,
|
||||
showDraftView: () => undefined,
|
||||
showSessionView: () => undefined,
|
||||
clearDraftForScope: () => undefined,
|
||||
addDraftFiles: async () => undefined,
|
||||
removeDraftFile: () => undefined,
|
||||
createSession: () => session(),
|
||||
deleteSession: () => undefined,
|
||||
updateSessionTitle: () => undefined,
|
||||
updateSessionExternalSessionId: () => undefined,
|
||||
addMessageToSession: () => undefined,
|
||||
updateLastMessage: () => undefined,
|
||||
updateMessageById: () => undefined,
|
||||
persistContextCompaction: () => undefined,
|
||||
providers: [],
|
||||
activeProviderId: '',
|
||||
activeModelId: '',
|
||||
defaultAgentId: 'catty',
|
||||
toolIntegrationMode: 'mcp',
|
||||
externalAgents: [],
|
||||
agentModelMap: {},
|
||||
setAgentModel: () => undefined,
|
||||
agentProviderMap: {},
|
||||
setAgentProvider: () => undefined,
|
||||
agentThinkingMap: {},
|
||||
setAgentThinking: () => undefined,
|
||||
globalPermissionMode: 'auto',
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
isVisible: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('send preflight aborts after the panel unmounts or the scope changes', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /sendEpochRef/);
|
||||
assert.match(source, /if \(isSendStale\(\)\) return;/);
|
||||
});
|
||||
|
||||
test('send preflight locks header agent and new-chat controls', () => {
|
||||
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
||||
assert.match(panel, /sending=\{isSending\}/);
|
||||
assert.match(content, /disabled=\{sending\}/);
|
||||
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
|
||||
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
|
||||
});
|
||||
|
||||
test('first send awaits provider sync instead of idle-deferring it', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /await sendBridge\.aiSyncProviders\(providers\)/);
|
||||
assert.match(source, /await sendBridge\.aiSyncWebSearch\(/);
|
||||
assert.doesNotMatch(source, /scheduleWhenAiComposerIdle\(\(\) => \{\s*void bridge\.aiSyncProviders/);
|
||||
});
|
||||
|
||||
test('send and new chat discard pending composer text', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /discardPendingComposerText/);
|
||||
assert.match(source, /if \(!options\?\.keepPendingText\) discardPendingComposerText\(\)/);
|
||||
});
|
||||
|
||||
test('text-only draft writes do not emit AI state changes', () => {
|
||||
const source = readFileSync(new URL('../application/state/useAIState.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /draftsByScopeEqualIgnoringAllComposerText/);
|
||||
assert.match(source, /if \(textOnly\) return;/);
|
||||
});
|
||||
|
||||
test('first composer keystroke does not create a store draft synchronously', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
const setter = source.slice(
|
||||
source.indexOf('const setInputValue = useCallback'),
|
||||
source.indexOf('const addFiles = useCallback'),
|
||||
);
|
||||
assert.match(setter, /pendingComposerTextRef\.current = value/);
|
||||
assert.doesNotMatch(setter, /enterScopeDraftMode/);
|
||||
assert.doesNotMatch(setter, /setTimeout/);
|
||||
});
|
||||
|
||||
test('hidden empty AI side panel can release its subtree', () => {
|
||||
const props = baseProps();
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), false);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), false);
|
||||
});
|
||||
|
||||
test('hidden AI side panel is retained when it has draft text', () => {
|
||||
const props = baseProps({
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': draft({ text: 'hello' }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
|
||||
});
|
||||
|
||||
test('hidden AI side panel is retained when it has session messages', () => {
|
||||
const props = baseProps({
|
||||
activeSessionIdMap: { 'terminal:terminal-1': 'session-1' },
|
||||
sessions: [
|
||||
session({
|
||||
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
|
||||
});
|
||||
|
||||
test('visible AI side panel is always mounted even when empty', () => {
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
|
||||
});
|
||||
|
||||
test('visible empty draft renders the input immediately without preparing state', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: 'en' },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(AIChatSidePanel, baseProps({
|
||||
isVisible: true,
|
||||
sessions: [session({ id: 'session-history' })],
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': draft(),
|
||||
},
|
||||
panelViewByScope: {
|
||||
'terminal:terminal-1': { mode: 'draft' },
|
||||
},
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /textarea/);
|
||||
assert.doesNotMatch(markup, /data-section="ai-chat-panel-preparing"/);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when retained content becomes visible again', () => {
|
||||
const hiddenProps = baseProps({
|
||||
isVisible: false,
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': draft({ text: 'hello' }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(
|
||||
hiddenProps,
|
||||
{ ...hiddenProps, isVisible: true },
|
||||
), false);
|
||||
});
|
||||
|
||||
test('hidden retained AI side panel keeps the composer and skips message content', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: 'en' },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(AIChatSidePanel, baseProps({
|
||||
isVisible: false,
|
||||
activeSessionIdMap: { 'terminal:terminal-1': 'session-1' },
|
||||
sessions: [
|
||||
session({
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', content: 'hidden-user-message', timestamp: 1 },
|
||||
{ id: 'm2', role: 'assistant', content: 'hidden-assistant-message', timestamp: 2 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': draft({ text: 'draft still retained' }),
|
||||
},
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-section="ai-chat-panel-retained"/);
|
||||
assert.match(markup, /inert/);
|
||||
assert.match(markup, /aria-hidden/);
|
||||
assert.match(markup, /textarea/);
|
||||
assert.doesNotMatch(markup, /hidden-user-message/);
|
||||
assert.doesNotMatch(markup, /hidden-assistant-message/);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when command timeout changes', () => {
|
||||
const props = baseProps({
|
||||
isVisible: true,
|
||||
commandTimeout: 60,
|
||||
});
|
||||
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(
|
||||
props,
|
||||
{ ...props, commandTimeout: 86_400 },
|
||||
), false);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when response wait time changes', () => {
|
||||
const props = baseProps({
|
||||
isVisible: true,
|
||||
responseIdleTimeout: 120,
|
||||
});
|
||||
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(
|
||||
props,
|
||||
{ ...props, responseIdleTimeout: 600 },
|
||||
), false);
|
||||
});
|
||||
|
||||
test('AI side panel skips re-render when only a sibling scope session object changes', () => {
|
||||
const own = session({ id: 'session-1', scope: { type: 'terminal', targetId: 'terminal-1' } });
|
||||
const siblingA = session({ id: 'session-2', scope: { type: 'terminal', targetId: 'terminal-2' } });
|
||||
const siblingB = session({ id: 'session-2', scope: { type: 'terminal', targetId: 'terminal-2' } });
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
sessions: [own, siblingA],
|
||||
});
|
||||
const next = {
|
||||
...prev,
|
||||
sessions: [own, siblingB],
|
||||
};
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(prev, next), true);
|
||||
|
||||
const ownStreamed = session({ id: 'session-1', scope: { type: 'terminal', targetId: 'terminal-1' } });
|
||||
assert.equal(
|
||||
aiChatSidePanelPropsAreEqual(prev, { ...prev, sessions: [ownStreamed, siblingA] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('AI side panel skips re-render when only the composer draft text changes', () => {
|
||||
const empty = draft({ text: '' });
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': empty,
|
||||
},
|
||||
});
|
||||
const next = {
|
||||
...prev,
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': { ...empty, text: '你好', updatedAt: 2 },
|
||||
},
|
||||
};
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(prev, next), true);
|
||||
});
|
||||
|
||||
test('workspace AI panel memo follows visible inherited session, not nonmember terminal maps', () => {
|
||||
const visibleMemberChat = session({
|
||||
id: 'chat-visible',
|
||||
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
|
||||
});
|
||||
const hiddenFocusedChat = session({
|
||||
id: 'chat-hidden',
|
||||
scope: { type: 'terminal', targetId: 'terminal-other', hostIds: ['host-other'] },
|
||||
});
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: 'ws-1',
|
||||
focusedSessionId: 'terminal-a',
|
||||
terminalSessions: [
|
||||
{
|
||||
sessionId: 'terminal-a',
|
||||
hostId: 'host-a',
|
||||
hostname: 'a.example',
|
||||
label: 'A',
|
||||
connected: true,
|
||||
},
|
||||
{
|
||||
sessionId: 'terminal-b',
|
||||
hostId: 'host-b',
|
||||
hostname: 'b.example',
|
||||
label: 'B',
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
activeSessionIdMap: {
|
||||
'terminal:terminal-outside': 'chat-hidden',
|
||||
'terminal:terminal-b': 'chat-visible',
|
||||
},
|
||||
sessions: [visibleMemberChat, hiddenFocusedChat],
|
||||
});
|
||||
const streamedVisible = session({
|
||||
id: 'chat-visible',
|
||||
scope: { type: 'terminal', targetId: 'terminal-b', hostIds: ['host-b'] },
|
||||
messages: [{ id: 'm1', role: 'assistant', content: 'stream', timestamp: 2 }],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
aiChatSidePanelPropsAreEqual(prev, { ...prev, sessions: [streamedVisible, hiddenFocusedChat] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('merged AI panel displays a member chat resumed from an older terminal', () => {
|
||||
const resumed = session({
|
||||
id: 'resumed-chat',
|
||||
scope: { type: 'terminal', targetId: 'closed-terminal', hostIds: ['host-a'] },
|
||||
messages: [{ id: 'm1', role: 'user', content: 'keep-resumed-conversation', timestamp: 1 }],
|
||||
});
|
||||
const props = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: 'merged',
|
||||
focusedSessionId: 'terminal-a',
|
||||
scopeHostIds: ['host-a'],
|
||||
terminalSessions: [{ sessionId: 'terminal-a', hostId: 'host-a', hostname: 'a', label: 'A', connected: true }],
|
||||
sessions: [resumed],
|
||||
activeSessionIdMap: { 'terminal:terminal-a': resumed.id },
|
||||
});
|
||||
const render = (panelProps: AIChatSidePanelProps) => renderToStaticMarkup(
|
||||
React.createElement(I18nProvider, { locale: 'en' },
|
||||
React.createElement(TooltipProvider, null,
|
||||
React.createElement(AIChatSidePanel, panelProps))),
|
||||
);
|
||||
// Check both the first workspace paint and the explicit merge handoff.
|
||||
assert.match(render(props), /keep-resumed-conversation/);
|
||||
assert.match(render({
|
||||
...props,
|
||||
activeSessionIdMap: { ...props.activeSessionIdMap, 'workspace:merged': resumed.id },
|
||||
panelViewByScope: { 'workspace:merged': { mode: 'session', sessionId: resumed.id } },
|
||||
}), /keep-resumed-conversation/);
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(props, {
|
||||
...props,
|
||||
sessions: [{ ...resumed, messages: [...resumed.messages, {
|
||||
id: 'm2', role: 'assistant', content: 'continued response', timestamp: 2,
|
||||
}] }],
|
||||
}), false);
|
||||
});
|
||||
1984
components/AIChatSidePanel.tsx
Normal file
1984
components/AIChatSidePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
135
components/AIChatSidePanel.types.ts
Normal file
135
components/AIChatSidePanel.types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import type { Host, Snippet, VaultNote } from '../types';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface AIChatSidePanelProps {
|
||||
// Session state (per-scope)
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
draftsByScope: Partial<Record<string, AIDraft>>;
|
||||
panelViewByScope: Partial<Record<string, AIPanelView>>;
|
||||
setActiveSessionId: (scopeKey: string, id: string | null) => void;
|
||||
ensureDraftForScope: (scopeKey: string, agentId: string) => void;
|
||||
updateDraft: (
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
) => void;
|
||||
showDraftView: (scopeKey: string) => void;
|
||||
showSessionView: (scopeKey: string, sessionId: string) => void;
|
||||
clearDraftForScope: (scopeKey: string) => void;
|
||||
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
|
||||
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
updateMessageById: (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
persistContextCompaction: (
|
||||
sessionId: string,
|
||||
compaction: import('../infrastructure/ai/types').AISessionContextCompaction,
|
||||
) => void;
|
||||
// Provider config
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
activeModelId: string;
|
||||
|
||||
// Agent info
|
||||
defaultAgentId: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
agentProviderMap: Record<string, string>;
|
||||
setAgentProvider: (agentId: string, providerId: string) => void;
|
||||
agentThinkingMap: Record<string, string>;
|
||||
setAgentThinking: (agentId: string, thinkingLevel: string) => void;
|
||||
updateProvider?: (id: string, updates: Partial<ProviderConfig>) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
commandTimeout?: number;
|
||||
responseIdleTimeout?: number;
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Quick messages (slash prompts)
|
||||
quickMessages?: AIQuickMessage[];
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds?: string[];
|
||||
scopeLabel?: string;
|
||||
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
||||
focusedSessionId?: string;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => ExecutorContext;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
|
||||
// Vault artifact navigation (from AI chat tool results)
|
||||
notes?: VaultNote[];
|
||||
hosts?: Host[];
|
||||
snippets?: Snippet[];
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSnippet?: (snippetId: string) => void;
|
||||
onOpenVaultSection?: (section: 'notes' | 'hosts' | 'snippets') => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
399
components/AIChatSidePanelHelpers.test.tsx
Normal file
399
components/AIChatSidePanelHelpers.test.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildCursorListModelsAgentEnv,
|
||||
buildSdkRuntimeModelCacheKey,
|
||||
canonicalizeEffortEncodedModelId,
|
||||
createSdkRuntimeModelCache,
|
||||
agentModelPresetsShallowEqual,
|
||||
mergeFallbackThinkingLevels,
|
||||
modelPresetsContainId,
|
||||
normalizeStoredAgentModelSelection,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
shouldUseStoredAgentModel,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
|
||||
test('buildCursorListModelsAgentEnv injects cli-login auth mode for list-models', () => {
|
||||
assert.deepEqual(
|
||||
buildCursorListModelsAgentEnv({
|
||||
command: '/Users/me/.local/bin/cursor-agent',
|
||||
cursorAuthMode: 'cli-login',
|
||||
env: { HOME: '/Users/me' },
|
||||
}),
|
||||
{
|
||||
HOME: '/Users/me',
|
||||
NETCATTY_CURSOR_AUTH_MODE: 'cli-login',
|
||||
NETCATTY_CURSOR_CLI_BIN: '/Users/me/.local/bin/cursor-agent',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCursorListModelsAgentEnv defaults to api-key without injecting CLI bin', () => {
|
||||
assert.deepEqual(
|
||||
buildCursorListModelsAgentEnv({
|
||||
command: 'cursor',
|
||||
env: {},
|
||||
}),
|
||||
{
|
||||
NETCATTY_CURSOR_AUTH_MODE: 'api-key',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCursorListModelsAgentEnv({
|
||||
command: 'cursor',
|
||||
cursorAuthMode: 'api-key',
|
||||
}),
|
||||
{
|
||||
NETCATTY_CURSOR_AUTH_MODE: 'api-key',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Cursor auth mode changes invalidate the SDK model cache key', () => {
|
||||
const base = {
|
||||
id: 'managed_cursor',
|
||||
command: '/Users/me/.local/bin/cursor-agent',
|
||||
sdkBackend: 'cursor',
|
||||
};
|
||||
const apiKey = buildSdkRuntimeModelCacheKey({
|
||||
...base,
|
||||
cursorAuthMode: 'api-key',
|
||||
});
|
||||
const cliLogin = buildSdkRuntimeModelCacheKey({
|
||||
...base,
|
||||
cursorAuthMode: 'cli-login',
|
||||
});
|
||||
assert.notEqual(apiKey, cliLogin);
|
||||
});
|
||||
|
||||
test('canonicalizeEffortEncodedModelId collapses Cursor query-string effort', () => {
|
||||
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low'), 'gpt-5/low');
|
||||
assert.equal(canonicalizeEffortEncodedModelId('gpt-5/high'), 'gpt-5/high');
|
||||
assert.equal(canonicalizeEffortEncodedModelId('gpt-5?effort=low&mode=fast'), 'gpt-5?effort=low&mode=fast');
|
||||
});
|
||||
|
||||
test('mergeFallbackThinkingLevels fills missing runtime effort catalogs', () => {
|
||||
const merged = mergeFallbackThinkingLevels(
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5' }, { id: 'auto', name: 'Auto' }],
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
|
||||
);
|
||||
assert.deepEqual(merged[0]?.thinkingLevels, ['low', 'medium', 'high']);
|
||||
assert.equal(merged[0]?.defaultThinkingLevel, 'medium');
|
||||
assert.equal(merged[1]?.thinkingLevels, undefined);
|
||||
const partial = mergeFallbackThinkingLevels(
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }],
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
|
||||
);
|
||||
assert.deepEqual(partial[0]?.thinkingLevels, ['low']);
|
||||
assert.deepEqual(mergeFallbackThinkingLevels([], [{ id: 'gpt-5.5', name: 'GPT-5.5' }]), []);
|
||||
const alreadyFilled = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }];
|
||||
assert.equal(
|
||||
mergeFallbackThinkingLevels(
|
||||
alreadyFilled,
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'] }],
|
||||
),
|
||||
alreadyFilled,
|
||||
);
|
||||
});
|
||||
|
||||
test('agentModelPresetsShallowEqual ignores array identity when contents match', () => {
|
||||
const left = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
|
||||
const right = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }];
|
||||
assert.equal(agentModelPresetsShallowEqual(left, right), true);
|
||||
assert.equal(
|
||||
agentModelPresetsShallowEqual(left, [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['high'] }]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('modelPresetsContainId matches plain and thinking-level model ids', () => {
|
||||
const presets: AgentModelPreset[] = [
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
|
||||
{ id: 'claude-sonnet', name: 'Claude Sonnet' },
|
||||
];
|
||||
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/high'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5?effort=high'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'claude-sonnet'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/medium'), false);
|
||||
});
|
||||
|
||||
test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () => {
|
||||
const agent = (sdkBackend: string): ExternalAgentConfig => ({
|
||||
id: `discovered_${sdkBackend}`,
|
||||
name: sdkBackend,
|
||||
command: sdkBackend,
|
||||
enabled: true,
|
||||
sdkBackend,
|
||||
});
|
||||
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('claude')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('copilot')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('cursor')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('grok')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
|
||||
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
|
||||
});
|
||||
|
||||
test('Codex App Server model discovery uses a separate cache identity', () => {
|
||||
const sdk = buildSdkRuntimeModelCacheKey({
|
||||
id: 'discovered_codex',
|
||||
command: '/bin/codex',
|
||||
sdkBackend: 'codex',
|
||||
codexRuntime: 'sdk',
|
||||
});
|
||||
const appServer = buildSdkRuntimeModelCacheKey({
|
||||
id: 'discovered_codex',
|
||||
command: '/bin/codex',
|
||||
sdkBackend: 'codex',
|
||||
codexRuntime: 'app-server',
|
||||
});
|
||||
assert.notEqual(sdk, appServer);
|
||||
});
|
||||
|
||||
test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is returned', () => {
|
||||
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', undefined, []), true);
|
||||
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'openai/gpt-5.1', []), true);
|
||||
assert.equal(
|
||||
shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'anthropic/claude-sonnet', [
|
||||
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAdoptSdkCurrentModel(null, undefined, []), false);
|
||||
});
|
||||
|
||||
test('normalizeStoredAgentModelSelection rewrites Cursor query effort but not extra params', () => {
|
||||
const presets: AgentModelPreset[] = [{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
thinkingLevels: ['low', 'medium', 'high'],
|
||||
defaultThinkingLevel: 'medium',
|
||||
}];
|
||||
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5?effort=low', presets), 'gpt-5.5/low');
|
||||
assert.equal(normalizeStoredAgentModelSelection('gpt-5.5/high', presets), 'gpt-5.5/high');
|
||||
assert.equal(
|
||||
normalizeStoredAgentModelSelection('gpt-5.5?effort=low&mode=fast', presets),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', () => {
|
||||
const presets: AgentModelPreset[] = [{
|
||||
id: 'glm-5.1',
|
||||
name: 'GLM 5.1',
|
||||
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
|
||||
defaultThinkingLevel: 'medium',
|
||||
encodeDefaultThinking: false,
|
||||
}];
|
||||
assert.equal(normalizeStoredAgentModelSelection('glm-5.1', presets), 'glm-5.1');
|
||||
assert.equal(normalizeStoredAgentModelSelection('glm-5.1/high', presets), 'glm-5.1/high');
|
||||
});
|
||||
|
||||
test('legacy plain model selections keep their model and gain its default reasoning effort', () => {
|
||||
const presets: AgentModelPreset[] = [{
|
||||
id: 'grok-4.5',
|
||||
name: 'Grok 4.5',
|
||||
thinkingLevels: ['high', 'medium', 'low'],
|
||||
defaultThinkingLevel: 'high',
|
||||
}, {
|
||||
id: 'grok-4.6',
|
||||
name: 'Grok 4.6',
|
||||
thinkingLevels: ['xhigh', 'high', 'medium', 'low'],
|
||||
defaultThinkingLevel: 'high',
|
||||
}];
|
||||
|
||||
assert.equal(modelPresetsContainId(presets, 'grok-4.5'), true);
|
||||
assert.equal(normalizeStoredAgentModelSelection('grok-4.5', presets), 'grok-4.5/high');
|
||||
assert.equal(normalizeStoredAgentModelSelection('grok-4.5/medium', presets), 'grok-4.5/medium');
|
||||
assert.equal(
|
||||
shouldAdoptSdkCurrentModel('grok-4.6/high', 'grok-4.5', presets),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSdkRuntimeModelPresets preserves SDK current model without a catalog', () => {
|
||||
assert.deepEqual(normalizeSdkRuntimeModelPresets([], 'custom/provider-model'), [
|
||||
{ id: 'custom/provider-model', name: 'custom/provider-model' },
|
||||
]);
|
||||
assert.deepEqual(normalizeSdkRuntimeModelPresets([], null), []);
|
||||
assert.deepEqual(
|
||||
normalizeSdkRuntimeModelPresets([{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }], 'custom/provider-model'),
|
||||
[{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldUseStoredAgentModel trusts SDK defaults when no runtime list is returned', () => {
|
||||
const opencodeAgent: ExternalAgentConfig = {
|
||||
id: 'managed_opencode',
|
||||
name: 'OpenCode',
|
||||
command: 'opencode',
|
||||
enabled: true,
|
||||
sdkBackend: 'opencode',
|
||||
};
|
||||
|
||||
assert.equal(shouldUseStoredAgentModel('openai/gpt-5.1', [], opencodeAgent), true);
|
||||
assert.equal(shouldUseStoredAgentModel('openai/gpt-5.1', [], undefined), false);
|
||||
assert.equal(
|
||||
shouldUseStoredAgentModel('anthropic/claude-sonnet', [
|
||||
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
||||
], opencodeAgent),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseStoredAgentModel('openai/gpt-5.1', [
|
||||
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
||||
], opencodeAgent),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache coalesces concurrent refreshes', async () => {
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
let calls = 0;
|
||||
let resolveLoad!: (value: { currentModelId: string | null; models: AgentModelPreset[] }) => void;
|
||||
const load = () => {
|
||||
calls += 1;
|
||||
return new Promise<{ currentModelId: string | null; models: AgentModelPreset[] }>((resolve) => {
|
||||
resolveLoad = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
const first = cache.refresh('opencode:/opt/bin/opencode', load);
|
||||
const second = cache.refresh('opencode:/opt/bin/opencode', load);
|
||||
|
||||
assert.equal(first, second);
|
||||
assert.equal(calls, 1);
|
||||
|
||||
resolveLoad({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
});
|
||||
assert.deepEqual(await first, {
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('SDK runtime model cache serves cached models while background refresh updates them', async () => {
|
||||
let now = 1_000;
|
||||
const cache = createSdkRuntimeModelCache({ now: () => now });
|
||||
|
||||
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
}));
|
||||
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
|
||||
now = 2_000;
|
||||
const refresh = cache.refresh(
|
||||
'opencode:/opt/bin/opencode',
|
||||
async () => ({
|
||||
currentModelId: 'anthropic/claude-sonnet-4-6',
|
||||
models: [{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }],
|
||||
}),
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
|
||||
await refresh;
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache keeps the previous catalog when a refresh fails', async () => {
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
|
||||
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
}));
|
||||
|
||||
await assert.rejects(
|
||||
cache.refresh(
|
||||
'opencode:/opt/bin/opencode',
|
||||
async () => {
|
||||
throw new Error('OpenCode unavailable');
|
||||
},
|
||||
{ force: true },
|
||||
),
|
||||
/OpenCode unavailable/,
|
||||
);
|
||||
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache ignores degraded empty catalogs', async () => {
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
|
||||
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
}));
|
||||
|
||||
const refreshed = await cache.refresh(
|
||||
'opencode:/opt/bin/opencode',
|
||||
async () => ({ currentModelId: null, models: [] }),
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(refreshed.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
assert.deepEqual(cache.read('opencode:/opt/bin/opencode')?.models, [
|
||||
{ id: 'openai/gpt-5.1', name: 'GPT-5.1' },
|
||||
]);
|
||||
|
||||
const emptyCache = createSdkRuntimeModelCache();
|
||||
await emptyCache.refresh('opencode:/usr/bin/opencode', async () => ({
|
||||
currentModelId: null,
|
||||
models: [],
|
||||
}));
|
||||
assert.equal(emptyCache.read('opencode:/usr/bin/opencode'), null);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache evicts expired catalogs', async () => {
|
||||
let now = 1_000;
|
||||
const cache = createSdkRuntimeModelCache({ ttlMs: 100, now: () => now });
|
||||
|
||||
await cache.refresh('opencode:/opt/bin/opencode', async () => ({
|
||||
currentModelId: 'openai/gpt-5.1',
|
||||
models: [{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
}));
|
||||
assert.equal(cache.size(), 1);
|
||||
|
||||
now = 1_101;
|
||||
assert.equal(cache.read('opencode:/opt/bin/opencode'), null);
|
||||
assert.equal(cache.size(), 0);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache stays bounded under agent configuration churn', async () => {
|
||||
const cache = createSdkRuntimeModelCache({ maxEntries: 4 });
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await cache.refresh(`agent:${index}`, async () => ({
|
||||
currentModelId: `model-${index}`,
|
||||
models: [{ id: `model-${index}`, name: `Model ${index}` }],
|
||||
}));
|
||||
}
|
||||
|
||||
assert.equal(cache.size(), 4);
|
||||
assert.equal(cache.read('agent:0'), null);
|
||||
assert.equal(cache.read('agent:19')?.currentModelId, 'model-19');
|
||||
});
|
||||
297
components/AIChatSidePanelHelpers.ts
Normal file
297
components/AIChatSidePanelHelpers.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
resolveAgentModelSelection,
|
||||
type AgentModelPreset,
|
||||
type ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
|
||||
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
|
||||
|
||||
export { canonicalizeEffortEncodedModelId };
|
||||
|
||||
export type SdkRuntimeModelCatalog = {
|
||||
currentModelId: string | null;
|
||||
models: AgentModelPreset[];
|
||||
};
|
||||
|
||||
export type SdkRuntimeModelCacheEntry = SdkRuntimeModelCatalog & {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type SdkRuntimeModelCacheOptions = {
|
||||
ttlMs?: number;
|
||||
maxEntries?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
type SdkRuntimeModelRefreshOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
const SDK_RUNTIME_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const SDK_RUNTIME_MODEL_CACHE_MAX_ENTRIES = 64;
|
||||
// Keep in sync with main-process SDK_MODEL_CACHE_ENV_KEYS: profile-affecting
|
||||
// env must bust the renderer cache so we re-query after OpenCode config switches.
|
||||
const MODEL_CACHE_ENV_HINTS = [
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'XDG_CONFIG_HOME',
|
||||
'OPENCODE_BIN',
|
||||
'OPENCODE_CONFIG',
|
||||
'OPENCODE_CONFIG_DIR',
|
||||
'OPENCODE_CONFIG_CONTENT',
|
||||
'CLAUDE_CODE_EXECUTABLE',
|
||||
'CODEBUDDY_CODE_PATH',
|
||||
'CURSOR_API_KEY',
|
||||
'NETCATTY_CURSOR_AUTH_MODE',
|
||||
'NETCATTY_CURSOR_CLI_BIN',
|
||||
] as const;
|
||||
|
||||
function cloneCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
|
||||
return {
|
||||
currentModelId: catalog.currentModelId ?? null,
|
||||
models: [...catalog.models],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSdkRuntimeModelCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
|
||||
return {
|
||||
currentModelId: catalog.currentModelId ?? null,
|
||||
models: Array.isArray(catalog.models)
|
||||
? catalog.models.filter((model): model is AgentModelPreset => Boolean(model?.id))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject Cursor auth-mode env for list-models IPC.
|
||||
* Mirrors run-turn `buildAgentEnvWithStoredApiKey` (without decrypting API keys):
|
||||
* persisted `agent.env` strips NETCATTY_CURSOR_* via sanitization, so list-models
|
||||
* must re-inject from `cursorAuthMode` / `command` or main defaults to api-key.
|
||||
*/
|
||||
export function buildCursorListModelsAgentEnv(agent: {
|
||||
env?: Record<string, string>;
|
||||
cursorAuthMode?: 'cli-login' | 'api-key';
|
||||
command?: string;
|
||||
}): Record<string, string> | undefined {
|
||||
const env = { ...(agent.env ?? {}) };
|
||||
const authMode = agent.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key';
|
||||
env.NETCATTY_CURSOR_AUTH_MODE = authMode;
|
||||
const cliBin = String(agent.command || '').trim();
|
||||
if (authMode === 'cli-login' && cliBin && cliBin !== 'cursor') {
|
||||
env.NETCATTY_CURSOR_CLI_BIN = cliBin;
|
||||
}
|
||||
return Object.keys(env).length > 0 ? env : undefined;
|
||||
}
|
||||
|
||||
export function buildSdkRuntimeModelCacheKey(agent: {
|
||||
id: string;
|
||||
command?: string;
|
||||
sdkBackend?: string;
|
||||
acpCommand?: string;
|
||||
env?: Record<string, string>;
|
||||
codexRuntime?: 'sdk' | 'app-server';
|
||||
grokRuntime?: 'acp' | 'streaming-json';
|
||||
cursorAuthMode?: 'cli-login' | 'api-key';
|
||||
}): string {
|
||||
const sdkBackend = agent.sdkBackend || agent.acpCommand || '';
|
||||
const envHints = MODEL_CACHE_ENV_HINTS.map((key) => `${key}=${agent.env?.[key] ?? ''}`);
|
||||
// cursorAuthMode is the source of truth when NETCATTY_CURSOR_AUTH_MODE was
|
||||
// stripped from persisted env; include it so toggling auth mode busts cache.
|
||||
const cursorAuth = sdkBackend === 'cursor'
|
||||
? (agent.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key')
|
||||
: '';
|
||||
const grokRuntime = sdkBackend === 'grok'
|
||||
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
|
||||
: '';
|
||||
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, ...envHints].join('\u0000');
|
||||
}
|
||||
|
||||
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
|
||||
const ttlMs = options.ttlMs ?? SDK_RUNTIME_MODEL_CACHE_TTL_MS;
|
||||
const maxEntries = Math.max(1, options.maxEntries ?? SDK_RUNTIME_MODEL_CACHE_MAX_ENTRIES);
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const entries = new Map<string, SdkRuntimeModelCacheEntry>();
|
||||
const inFlight = new Map<string, Promise<SdkRuntimeModelCatalog>>();
|
||||
|
||||
const pruneEntries = () => {
|
||||
const currentTime = now();
|
||||
for (const [key, entry] of entries) {
|
||||
if (currentTime - entry.updatedAt >= ttlMs) {
|
||||
entries.delete(key);
|
||||
}
|
||||
}
|
||||
while (entries.size > maxEntries) {
|
||||
const oldestKey = entries.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
entries.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
const touchEntry = (key: string, entry: SdkRuntimeModelCacheEntry) => {
|
||||
entries.delete(key);
|
||||
entries.set(key, entry);
|
||||
};
|
||||
|
||||
return {
|
||||
read(key: string): SdkRuntimeModelCacheEntry | null {
|
||||
pruneEntries();
|
||||
const entry = entries.get(key);
|
||||
if (entry) touchEntry(key, entry);
|
||||
return entry ? { ...cloneCatalog(entry), updatedAt: entry.updatedAt } : null;
|
||||
},
|
||||
size(): number {
|
||||
pruneEntries();
|
||||
return entries.size;
|
||||
},
|
||||
refresh(
|
||||
key: string,
|
||||
load: () => Promise<SdkRuntimeModelCatalog>,
|
||||
refreshOptions: SdkRuntimeModelRefreshOptions = {},
|
||||
): Promise<SdkRuntimeModelCatalog> {
|
||||
pruneEntries();
|
||||
const cached = entries.get(key);
|
||||
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
|
||||
touchEntry(key, cached);
|
||||
return Promise.resolve(cloneCatalog(cached));
|
||||
}
|
||||
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = Promise.resolve(load())
|
||||
.then((catalog) => {
|
||||
const normalized = normalizeSdkRuntimeModelCatalog(catalog);
|
||||
if (normalized.models.length === 0 && !normalized.currentModelId) {
|
||||
return cached ? cloneCatalog(cached) : cloneCatalog(normalized);
|
||||
}
|
||||
touchEntry(key, { ...cloneCatalog(normalized), updatedAt: now() });
|
||||
pruneEntries();
|
||||
return cloneCatalog(normalized);
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
|
||||
|
||||
export function mergeFallbackThinkingLevels(
|
||||
runtime: AgentModelPreset[],
|
||||
fallbacks: AgentModelPreset[],
|
||||
): AgentModelPreset[] {
|
||||
if (runtime.length === 0 || fallbacks.length === 0) return runtime;
|
||||
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
|
||||
let changed = false;
|
||||
const next = runtime.map((preset) => {
|
||||
if (preset.thinkingLevels?.length) return preset;
|
||||
const fallback = byId.get(preset.id);
|
||||
if (!fallback?.thinkingLevels?.length) return preset;
|
||||
changed = true;
|
||||
return {
|
||||
...preset,
|
||||
thinkingLevels: [...fallback.thinkingLevels],
|
||||
...(fallback.defaultThinkingLevel
|
||||
? { defaultThinkingLevel: fallback.defaultThinkingLevel }
|
||||
: {}),
|
||||
...(fallback.encodeDefaultThinking === false
|
||||
? { encodeDefaultThinking: false }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
return changed ? next : runtime;
|
||||
}
|
||||
|
||||
export function agentModelPresetsShallowEqual(
|
||||
left: AgentModelPreset[] | undefined,
|
||||
right: AgentModelPreset[] | undefined,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((preset, index) => {
|
||||
const other = right[index];
|
||||
return (
|
||||
preset.id === other.id
|
||||
&& preset.name === other.name
|
||||
&& preset.defaultThinkingLevel === other.defaultThinkingLevel
|
||||
&& (preset.thinkingLevels ?? []).join('\0') === (other.thinkingLevels ?? []).join('\0')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
const canonical = canonicalizeEffortEncodedModelId(modelId);
|
||||
if (preset.thinkingLevels?.length) {
|
||||
return preset.id === canonical
|
||||
|| preset.thinkingLevels.some((level) => `${preset.id}/${level}` === canonical);
|
||||
}
|
||||
return preset.id === canonical;
|
||||
}
|
||||
|
||||
export function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
|
||||
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
||||
}
|
||||
|
||||
export function normalizeStoredAgentModelSelection(
|
||||
storedModelId: string | null | undefined,
|
||||
presets: AgentModelPreset[],
|
||||
): string | undefined {
|
||||
if (!storedModelId) return undefined;
|
||||
const canonical = canonicalizeEffortEncodedModelId(storedModelId);
|
||||
const preset = presets.find((candidate) => modelPresetMatchesId(candidate, canonical));
|
||||
if (!preset) return undefined;
|
||||
return canonical === preset.id
|
||||
? resolveAgentModelSelection(preset)
|
||||
: canonical;
|
||||
}
|
||||
|
||||
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
|
||||
|| sdkBackend === 'claude'
|
||||
|| sdkBackend === 'copilot'
|
||||
|| sdkBackend === 'cursor'
|
||||
|| sdkBackend === 'codebuddy'
|
||||
|| sdkBackend === 'opencode'
|
||||
|| sdkBackend === 'grok';
|
||||
}
|
||||
|
||||
export function shouldAdoptSdkCurrentModel(
|
||||
currentModelId: string | null | undefined,
|
||||
storedModelId: string | null | undefined,
|
||||
runtimePresets: AgentModelPreset[],
|
||||
): boolean {
|
||||
if (!currentModelId) return false;
|
||||
return !storedModelId
|
||||
|| runtimePresets.length === 0
|
||||
|| !modelPresetsContainId(runtimePresets, storedModelId);
|
||||
}
|
||||
|
||||
export function normalizeSdkRuntimeModelPresets(
|
||||
models: AgentModelPreset[],
|
||||
currentModelId: string | null | undefined,
|
||||
): AgentModelPreset[] {
|
||||
if (models.length > 0) return models;
|
||||
if (!currentModelId) return [];
|
||||
return [{ id: currentModelId, name: currentModelId }];
|
||||
}
|
||||
|
||||
export function shouldUseStoredAgentModel(
|
||||
storedModelId: string | null | undefined,
|
||||
presets: AgentModelPreset[],
|
||||
agent?: ExternalAgentConfig,
|
||||
): boolean {
|
||||
if (!storedModelId) return false;
|
||||
return modelPresetsContainId(presets, storedModelId)
|
||||
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent));
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
40
components/AddToWorkspaceDialog.keyboard.test.ts
Normal file
40
components/AddToWorkspaceDialog.keyboard.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getWorkspaceTargetRowStateClass,
|
||||
shouldToggleWorkspaceTarget,
|
||||
} from './workspace/AddToWorkspaceDialog';
|
||||
|
||||
test('space toggles the current target when the search is empty', () => {
|
||||
assert.equal(shouldToggleWorkspaceTarget(' ', '', false, false), true);
|
||||
});
|
||||
|
||||
test('space remains available inside a non-empty search query', () => {
|
||||
assert.equal(shouldToggleWorkspaceTarget(' ', 'database server', false, false), false);
|
||||
});
|
||||
|
||||
test('plain Enter toggles while modified Enter remains the commit shortcut', () => {
|
||||
assert.equal(shouldToggleWorkspaceTarget('Enter', '', false, false), true);
|
||||
assert.equal(shouldToggleWorkspaceTarget('Enter', '', true, false), false);
|
||||
assert.equal(shouldToggleWorkspaceTarget('Enter', '', false, true), false);
|
||||
});
|
||||
|
||||
test('checked targets keep a visible background after the cursor moves away', () => {
|
||||
assert.equal(getWorkspaceTargetRowStateClass(false, false), 'hover:bg-muted/50');
|
||||
assert.equal(getWorkspaceTargetRowStateClass(true, false), 'bg-primary/15');
|
||||
assert.equal(getWorkspaceTargetRowStateClass(false, true), 'bg-primary/10');
|
||||
assert.equal(getWorkspaceTargetRowStateClass(true, true), 'bg-primary/20');
|
||||
});
|
||||
|
||||
test('clicking a target aligns the keyboard cursor with that target', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(source, /onClick=\{\(\) => handleTargetClick\(idx, LOCAL_ITEM_ID\)\}/);
|
||||
assert.match(source, /onClick=\{\(\) => handleTargetClick\(idx, host\.id\)\}/);
|
||||
assert.match(source, /handleTargetClick[\s\S]*inputRef\.current\?\.focus\(\)/);
|
||||
});
|
||||
1026
components/AppLockGate.runtime.test.tsx
Normal file
1026
components/AppLockGate.runtime.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
146
components/AppLockGate.test.ts
Normal file
146
components/AppLockGate.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
shouldDeferExternalActionWhileAppLocked,
|
||||
shouldNotifyAppLockGateRendererReady,
|
||||
shouldRenderAppLockGateChildren,
|
||||
} from "./AppLockGate.tsx";
|
||||
|
||||
test("shouldRenderAppLockGateChildren withholds startup-locked route children until unlock", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "startup",
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren force-renders terminal popup children before init", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: false,
|
||||
locked: true,
|
||||
lockReason: "startup",
|
||||
hasRenderedChildren: false,
|
||||
forceRenderChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren withholds first mount for idle and background locks", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "idle",
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "background",
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "manual",
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren withholds children before runtime initialization", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: false,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
hasRenderedChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren keeps mounted children for manual and idle locks", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "manual",
|
||||
hasRenderedChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "idle",
|
||||
hasRenderedChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRenderAppLockGateChildren keeps existing children mounted for reopen locks", () => {
|
||||
assert.equal(
|
||||
shouldRenderAppLockGateChildren({
|
||||
initialized: true,
|
||||
locked: true,
|
||||
lockReason: "startup",
|
||||
hasRenderedChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldNotifyAppLockGateRendererReady waits until locked children can mount", () => {
|
||||
assert.equal(
|
||||
shouldNotifyAppLockGateRendererReady({
|
||||
notifyRendererReady: true,
|
||||
renderChildren: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldNotifyAppLockGateRendererReady({
|
||||
notifyRendererReady: true,
|
||||
renderChildren: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldNotifyAppLockGateRendererReady({
|
||||
notifyRendererReady: false,
|
||||
renderChildren: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldDeferExternalActionWhileAppLocked queues actions only while locked", () => {
|
||||
assert.equal(shouldDeferExternalActionWhileAppLocked({ locked: true }), true);
|
||||
assert.equal(shouldDeferExternalActionWhileAppLocked({ locked: false }), false);
|
||||
});
|
||||
186
components/AppLockGate.tsx
Normal file
186
components/AppLockGate.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { useAppLockBridge } from '../application/state/useAppLockBridge';
|
||||
import { type AppLockReason, useAppLockState } from '../application/state/useAppLockState';
|
||||
import { useSettingsState } from '../application/state/useSettingsState';
|
||||
import { ToastProvider } from './ui/toast';
|
||||
import { TooltipProvider } from './ui/tooltip';
|
||||
import { AppLockOverlay } from './AppLockOverlay';
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type AppLockState = ReturnType<typeof useAppLockState>;
|
||||
|
||||
export interface AppLockGateRenderContext {
|
||||
settings: SettingsState;
|
||||
appLock: AppLockState;
|
||||
}
|
||||
|
||||
interface AppLockGateProps {
|
||||
children: (ctx: AppLockGateRenderContext) => React.ReactNode;
|
||||
notifyRendererReady?: boolean;
|
||||
/**
|
||||
* Mount route children immediately (even before app-lock init / while locked).
|
||||
* Terminal popups need this so their one-shot config IPC listeners register
|
||||
* before main's post-loadURL send is only held in preload pending state —
|
||||
* AppLockGate otherwise withholds children until async lock state resolves.
|
||||
*/
|
||||
forceRenderChildren?: boolean;
|
||||
settingsOptions?: Parameters<typeof useSettingsState>[0];
|
||||
}
|
||||
|
||||
interface AppLockGateDeps {
|
||||
useSettingsState: typeof useSettingsState;
|
||||
useAppLockState: typeof useAppLockState;
|
||||
useAppLockBridge: typeof useAppLockBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withhold first content mount while the gate is locked for any reason
|
||||
* (startup / idle / background / manual). A new renderer that reports ready
|
||||
* before unlock would let main deliver ssh:// deep links into App handlers and
|
||||
* start connections behind the lock overlay. Once children have mounted (e.g.
|
||||
* idle re-lock after use), keep them mounted under the overlay.
|
||||
*/
|
||||
export function shouldRenderAppLockGateChildren(input: {
|
||||
initialized: boolean;
|
||||
locked: boolean;
|
||||
lockReason: AppLockReason | null;
|
||||
hasRenderedChildren: boolean;
|
||||
forceRenderChildren?: boolean;
|
||||
}): boolean {
|
||||
if (input.forceRenderChildren) return true;
|
||||
if (!input.initialized && !input.hasRenderedChildren) return false;
|
||||
if (input.locked && !input.hasRenderedChildren) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-link delivery waits on rendererReady. Only mark the renderer ready once
|
||||
* app children are allowed to mount, so unlock (not the 15s readiness timeout)
|
||||
* is what unblocks pending ssh:// links after a locked first paint.
|
||||
*/
|
||||
export function shouldNotifyAppLockGateRendererReady(input: {
|
||||
notifyRendererReady: boolean;
|
||||
renderChildren: boolean;
|
||||
}): boolean {
|
||||
return input.notifyRendererReady && input.renderChildren;
|
||||
}
|
||||
|
||||
/** Queue external actions (e.g. ssh:// deep links) while the app lock is held. */
|
||||
export function shouldDeferExternalActionWhileAppLocked(input: {
|
||||
locked: boolean;
|
||||
}): boolean {
|
||||
return input.locked === true;
|
||||
}
|
||||
|
||||
export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGateProps> {
|
||||
const AppLockGateImpl: React.FC<AppLockGateProps> = ({
|
||||
children,
|
||||
notifyRendererReady = true,
|
||||
forceRenderChildren = false,
|
||||
settingsOptions,
|
||||
}) => {
|
||||
const settings = deps.useSettingsState(settingsOptions);
|
||||
const appLock = deps.useAppLockState(settings.appLockSettings);
|
||||
const {
|
||||
notifyRendererReady: notifyAppLockRendererReady,
|
||||
onAppLockReopen,
|
||||
} = deps.useAppLockBridge();
|
||||
const hasRenderedChildrenRef = useRef(false);
|
||||
const [reopenSignal, setReopenSignal] = useState(0);
|
||||
const renderChildren = shouldRenderAppLockGateChildren({
|
||||
initialized: appLock.initialized,
|
||||
locked: appLock.locked,
|
||||
lockReason: appLock.lockReason,
|
||||
hasRenderedChildren: hasRenderedChildrenRef.current,
|
||||
forceRenderChildren,
|
||||
});
|
||||
if (renderChildren) {
|
||||
hasRenderedChildrenRef.current = true;
|
||||
}
|
||||
const shouldNotifyRendererReady = shouldNotifyAppLockGateRendererReady({
|
||||
notifyRendererReady,
|
||||
renderChildren,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
splash.classList.add('fade-out');
|
||||
setTimeout(() => splash.remove(), 200);
|
||||
}
|
||||
if (shouldNotifyRendererReady) {
|
||||
notifyAppLockRendererReady();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [notifyAppLockRendererReady, shouldNotifyRendererReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAppLockReopen(() => {
|
||||
setReopenSignal((current) => current + 1);
|
||||
void appLock.resync?.();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [appLock, onAppLockReopen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityOrFocus = () => {
|
||||
void appLock.resync?.();
|
||||
};
|
||||
window.addEventListener('focus', handleVisibilityOrFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleVisibilityOrFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
|
||||
};
|
||||
}, [appLock]);
|
||||
|
||||
// When re-locked after first mount, children stay mounted under the overlay.
|
||||
// Mark them inert so pointer/keyboard/AT cannot reach underlying controls
|
||||
// (sessions, editors, side panels) while the lock screen is up.
|
||||
const lockedBackground = appLock.locked === true;
|
||||
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
{renderChildren ? (
|
||||
<div
|
||||
// React 19 supports the HTML inert attribute; when true, the
|
||||
// subtree is non-interactive and removed from sequential focus.
|
||||
inert={lockedBackground ? true : undefined}
|
||||
aria-hidden={lockedBackground || undefined}
|
||||
className={lockedBackground ? 'h-full pointer-events-none' : 'h-full'}
|
||||
data-app-lock-background={lockedBackground ? 'locked' : 'unlocked'}
|
||||
>
|
||||
{children({ settings, appLock })}
|
||||
</div>
|
||||
) : null}
|
||||
<AppLockOverlay
|
||||
locked={appLock.locked}
|
||||
reason={appLock.lockReason}
|
||||
onUnlock={appLock.unlock}
|
||||
systemUnlockStatus={appLock.systemUnlockStatus}
|
||||
onSystemUnlock={appLock.unlockWithSystemAuth}
|
||||
onResetAppLock={appLock.reset}
|
||||
autoPromptSystemUnlock={settings.appLockSettings.systemUnlockAutoPromptEnabled}
|
||||
reopenSignal={reopenSignal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return AppLockGateImpl;
|
||||
}
|
||||
|
||||
export const AppLockGate = createAppLockGate({
|
||||
useSettingsState,
|
||||
useAppLockState,
|
||||
useAppLockBridge,
|
||||
});
|
||||
902
components/AppLockOverlay.runtime.test.tsx
Normal file
902
components/AppLockOverlay.runtime.test.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { APP_LOCK_AUTO_PROMPT_DELAY_MS, AppLockOverlay } from "./AppLockOverlay.tsx";
|
||||
import {
|
||||
createDomRenderer,
|
||||
dispatchDomEvent,
|
||||
flushEffects,
|
||||
installDomEnvironment,
|
||||
runWithAct,
|
||||
} from "./test-support/renderReactDom.tsx";
|
||||
|
||||
async function waitForAutoPrompt(dom: ReturnType<typeof installDomEnvironment>) {
|
||||
await runWithAct(async () => {
|
||||
await new Promise((resolve) => dom.window.setTimeout(resolve, APP_LOCK_AUTO_PROMPT_DELAY_MS + 50));
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
test("AppLockOverlay shows incorrect-password error and clears it after editing", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const unlockAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async (password) => {
|
||||
unlockAttempts.push(password);
|
||||
return { ok: false, error: "incorrect" as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
const form = dom.document.querySelector("form");
|
||||
const passwordLabel = dom.document.querySelector('label[for="app-lock-password"]');
|
||||
const lockIcon = dom.document.querySelector('[data-testid="app-lock-password-lock-icon"]');
|
||||
assert.ok(input);
|
||||
assert.ok(form);
|
||||
assert.ok(passwordLabel);
|
||||
assert.ok(lockIcon);
|
||||
assert.equal(input.getAttribute("placeholder"), "Enter password to unlock");
|
||||
assert.match(passwordLabel.getAttribute("class") ?? "", /sr-only/);
|
||||
assert.equal(lockIcon.getAttribute("aria-hidden"), "true");
|
||||
assert.doesNotMatch(lockIcon.getAttribute("class") ?? "", /border-l/);
|
||||
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "wrong");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(unlockAttempts, ["wrong"]);
|
||||
assert.match(dom.document.body.textContent ?? "", /Incorrect lock password/i);
|
||||
|
||||
setInputValue.call(input, "wrong-again");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Incorrect lock password/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay submits a whitespace-only password unchanged", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const unlockAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async (password) => {
|
||||
unlockAttempts.push(password);
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
const form = dom.document.querySelector("form");
|
||||
assert.ok(input);
|
||||
assert.ok(form);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, " ");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(unlockAttempts, [" "]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reveals reset action after clicking Netcatty logo five times", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const resetAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async (currentPassword) => {
|
||||
resetAttempts.push(currentPassword);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
|
||||
const resetButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
|
||||
assert.ok(resetButton);
|
||||
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(resetAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay only reveals reset after five quick logo clicks", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await new Promise((resolve) => dom.window.setTimeout(resolve, 1600));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reset controls do not submit the unlock form", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let unlockCount = 0;
|
||||
const resetAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => {
|
||||
unlockCount += 1;
|
||||
return { ok: false, error: "incorrect" as const };
|
||||
},
|
||||
onResetAppLock: async (currentPassword) => {
|
||||
resetAttempts.push(currentPassword);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /forgot password/i);
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
await flushEffects();
|
||||
|
||||
const cancelButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Cancel/i.test(button.textContent ?? ""));
|
||||
assert.ok(cancelButton);
|
||||
await dispatchDomEvent(cancelButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
assert.equal(unlockCount, 0);
|
||||
assert.deepEqual(resetAttempts, []);
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
await flushEffects();
|
||||
const resetButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
|
||||
assert.ok(resetButton);
|
||||
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(unlockCount, 0);
|
||||
assert.deepEqual(resetAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay renders platform-specific system unlock button", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "win32",
|
||||
label: "Windows Hello",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-windows-icon"]'));
|
||||
|
||||
const button = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Windows Hello/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay localizes the system unlock button label", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "zh-CN" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: true as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /使用 Touch ID 解锁/i);
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-touch-id-icon"]'));
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay does not automatically request system unlock by default", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
const props = {
|
||||
locked: true,
|
||||
reason: "manual" as const,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
};
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.match(dom.document.body.textContent ?? "", /Use password instead/i);
|
||||
|
||||
const button = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reveals password fallback and cancels a delayed automatic prompt", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
autoPromptSystemUnlock: true,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
const usePasswordButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Use password instead/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(usePasswordButton);
|
||||
await dispatchDomEvent(usePasswordButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.ok(dom.document.getElementById("app-lock-password"));
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay shows animated feedback while system unlock is in flight", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let resolveSystemUnlock: ((result: { ok: true }) => void) | null = null;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => new Promise<{ ok: true }>((resolve) => {
|
||||
resolveSystemUnlock = resolve;
|
||||
}),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const systemButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(systemButton);
|
||||
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /Waiting for Touch ID/i);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
|
||||
assert.ok(resolveSystemUnlock);
|
||||
await runWithAct(async () => {
|
||||
resolveSystemUnlock?.({ ok: true });
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay automatically requests system unlock once when auto prompt is enabled", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
const props = {
|
||||
locked: true,
|
||||
reason: "manual" as const,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
};
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
|
||||
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay waits until the document is visible before auto system unlock", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
Object.defineProperty(dom.document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "hidden",
|
||||
});
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
|
||||
Object.defineProperty(dom.document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "visible",
|
||||
});
|
||||
await dispatchDomEvent(dom.document, new dom.window.Event("visibilitychange"));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay waits for window focus before auto system unlock", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let focused = false;
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
Object.defineProperty(dom.document, "hasFocus", {
|
||||
configurable: true,
|
||||
value: () => focused,
|
||||
});
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
|
||||
focused = true;
|
||||
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
|
||||
await flushEffects();
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay retries a reopen presentation after an in-flight auto prompt finishes", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
let resolveFirstUnlock: ((result: { ok: false; error: "cancelled" }) => void) | null = null;
|
||||
|
||||
try {
|
||||
const createOverlay = (reopenSignal: number) => React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "background",
|
||||
reopenSignal,
|
||||
autoPromptSystemUnlock: true,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
if (systemUnlockCount === 1) {
|
||||
return new Promise<{ ok: false; error: "cancelled" }>((resolve) => {
|
||||
resolveFirstUnlock = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: false as const, error: "cancelled" as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
await renderer.render(createOverlay(1));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await renderer.render(createOverlay(2));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
assert.ok(resolveFirstUnlock);
|
||||
await runWithAct(async () => {
|
||||
resolveFirstUnlock?.({ ok: false, error: "cancelled" });
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
|
||||
assert.equal(systemUnlockCount, 2);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay hides system unlock when unavailable", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: false,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: "unavailable",
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: true as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay keeps password fallback after system unlock failure", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const unlockAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async (password) => {
|
||||
unlockAttempts.push(password);
|
||||
return { ok: true as const };
|
||||
},
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: false as const, error: "cancelled" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
const systemButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(systemButton);
|
||||
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.match(dom.document.body.textContent ?? "", /System unlock was not completed/i);
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
const form = dom.document.querySelector("form");
|
||||
assert.ok(input);
|
||||
assert.ok(form);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(unlockAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay keeps a window drag region while lock controls stay interactive", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const overlay = dom.document.querySelector("[data-app-lock-overlay]");
|
||||
const form = overlay?.querySelector("form");
|
||||
assert.ok(overlay);
|
||||
assert.ok(form);
|
||||
assert.match(overlay.getAttribute("class") ?? "", /\bapp-drag\b/);
|
||||
assert.match(form.getAttribute("class") ?? "", /\bapp-no-drag\b/);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
25
components/AppLockOverlay.test.ts
Normal file
25
components/AppLockOverlay.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getAppLockErrorMessageKey,
|
||||
} from "./AppLockOverlay.tsx";
|
||||
|
||||
test("getAppLockErrorMessageKey maps unlock errors to localized message keys", () => {
|
||||
assert.equal(getAppLockErrorMessageKey("empty"), "appLock.error.emptyPassword");
|
||||
assert.equal(getAppLockErrorMessageKey("incorrect"), "appLock.error.incorrectPassword");
|
||||
assert.equal(getAppLockErrorMessageKey(null), null);
|
||||
});
|
||||
|
||||
test("AppLockOverlay marks the backdrop as a window drag region", () => {
|
||||
const source = readFileSync(new URL("./AppLockOverlay.tsx", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/className="flex w-full max-w-\[360px\] flex-col items-center gap-5 app-no-drag"/,
|
||||
);
|
||||
});
|
||||
522
components/AppLockOverlay.tsx
Normal file
522
components/AppLockOverlay.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Fingerprint, KeyRound, Loader2, LockKeyhole } from 'lucide-react';
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type {
|
||||
AppLockReason,
|
||||
AppLockSystemUnlockResult,
|
||||
AppLockSystemUnlockStatus,
|
||||
AppLockUnlockResult,
|
||||
} from '../application/state/useAppLockState';
|
||||
import { AppLogo } from './AppLogo';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
const RESET_REVEAL_CLICK_COUNT = 5;
|
||||
const RESET_REVEAL_WINDOW_MS = 1500;
|
||||
export const APP_LOCK_AUTO_PROMPT_DELAY_MS = 1200;
|
||||
|
||||
function isWindowInteractive(): boolean {
|
||||
return typeof document === 'undefined'
|
||||
|| (
|
||||
document.visibilityState !== 'hidden'
|
||||
&& (typeof document.hasFocus !== 'function' || document.hasFocus())
|
||||
);
|
||||
}
|
||||
|
||||
interface AppLockOverlayProps {
|
||||
locked: boolean;
|
||||
reason: AppLockReason | null;
|
||||
onUnlock: (password: string) => Promise<AppLockUnlockResult>;
|
||||
systemUnlockStatus?: AppLockSystemUnlockStatus;
|
||||
onSystemUnlock?: () => Promise<AppLockSystemUnlockResult>;
|
||||
onResetAppLock: (currentPassword: string) => Promise<void>;
|
||||
autoPromptSystemUnlock?: boolean;
|
||||
reopenSignal?: number;
|
||||
}
|
||||
|
||||
export function getAppLockErrorMessageKey(error: AppLockUnlockResult['error'] | null): string | null {
|
||||
switch (error) {
|
||||
case 'empty':
|
||||
return 'appLock.error.emptyPassword';
|
||||
case 'incorrect':
|
||||
return 'appLock.error.incorrectPassword';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const AppLockOverlay: React.FC<AppLockOverlayProps> = ({
|
||||
locked,
|
||||
reason,
|
||||
onUnlock,
|
||||
systemUnlockStatus,
|
||||
onSystemUnlock,
|
||||
onResetAppLock,
|
||||
autoPromptSystemUnlock = false,
|
||||
reopenSignal = 0,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<AppLockUnlockResult['error'] | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSystemUnlocking, setIsSystemUnlocking] = useState(false);
|
||||
const [isAutoPromptPending, setIsAutoPromptPending] = useState(false);
|
||||
const [systemUnlockError, setSystemUnlockError] = useState(false);
|
||||
const [passwordFallbackRequested, setPasswordFallbackRequested] = useState(false);
|
||||
// Value is only read inside functional updates; keep the setter alone.
|
||||
const [, setLogoClickCount] = useState(0);
|
||||
const [lastLogoClickAt, setLastLogoClickAt] = useState<number | null>(null);
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState(false);
|
||||
const [windowInteractive, setWindowInteractive] = useState(() => isWindowInteractive());
|
||||
const lastAutoUnlockPresentationRef = useRef<string | null>(null);
|
||||
const autoUnlockTimerRef = useRef<number | null>(null);
|
||||
const canUseSystemUnlock = Boolean(
|
||||
systemUnlockStatus?.enabled
|
||||
&& systemUnlockStatus.available
|
||||
&& systemUnlockStatus.label
|
||||
&& onSystemUnlock,
|
||||
);
|
||||
const showPasswordUnlock = !canUseSystemUnlock
|
||||
|| passwordFallbackRequested
|
||||
|| systemUnlockError
|
||||
|| showReset;
|
||||
|
||||
useEffect(() => {
|
||||
const updateWindowInteractive = () => {
|
||||
setWindowInteractive(isWindowInteractive());
|
||||
};
|
||||
|
||||
updateWindowInteractive();
|
||||
document.addEventListener('visibilitychange', updateWindowInteractive);
|
||||
window.addEventListener('focus', updateWindowInteractive);
|
||||
window.addEventListener('blur', updateWindowInteractive);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', updateWindowInteractive);
|
||||
window.removeEventListener('focus', updateWindowInteractive);
|
||||
window.removeEventListener('blur', updateWindowInteractive);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const overlayRootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locked) {
|
||||
if (autoUnlockTimerRef.current !== null) {
|
||||
window.clearTimeout(autoUnlockTimerRef.current);
|
||||
autoUnlockTimerRef.current = null;
|
||||
}
|
||||
setPassword('');
|
||||
setError(null);
|
||||
setIsSubmitting(false);
|
||||
setIsSystemUnlocking(false);
|
||||
setIsAutoPromptPending(false);
|
||||
setSystemUnlockError(false);
|
||||
setPasswordFallbackRequested(false);
|
||||
setLogoClickCount(0);
|
||||
setLastLogoClickAt(null);
|
||||
setShowReset(false);
|
||||
setIsResetting(false);
|
||||
setResetError(false);
|
||||
lastAutoUnlockPresentationRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!showPasswordUnlock) return;
|
||||
const timeout = window.setTimeout(() => passwordRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [locked, showPasswordUnlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locked || reason !== 'background' || reopenSignal <= 0) return;
|
||||
setPasswordFallbackRequested(false);
|
||||
setSystemUnlockError(false);
|
||||
}, [locked, reason, reopenSignal]);
|
||||
|
||||
// Soft focus trap + stop ALL app-level keydown while locked.
|
||||
// inert does not block window capture (shortkey/hotkey recorders) or window
|
||||
// bubble (Snippets Ctrl/Cmd+S). Use window capture + stopImmediate so peer
|
||||
// window handlers never run, without stopPropagation so the lock password
|
||||
// input still receives the event. Document bubble then stops before window
|
||||
// bubble listeners (Codex P2 on 81e1f779).
|
||||
useEffect(() => {
|
||||
if (!locked) return;
|
||||
|
||||
const onWindowCapture = (event: KeyboardEvent) => {
|
||||
// Suppress other window-level keydown handlers registered after us.
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
if (event.key !== 'Tab') return;
|
||||
const root = overlayRootRef.current;
|
||||
if (!root) return;
|
||||
const focusable = root.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input:not([disabled]), [href], select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !root.contains(active)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Unconditional: even when focus is on body, window bubble shortcuts must
|
||||
// not fire. Runs on document bubble before window bubble.
|
||||
const onDocumentBubble = (event: KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onWindowCapture, true);
|
||||
document.addEventListener('keydown', onDocumentBubble, false);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onWindowCapture, true);
|
||||
document.removeEventListener('keydown', onDocumentBubble, false);
|
||||
};
|
||||
}, [locked]);
|
||||
|
||||
// Context menus portal at z-index max (2147483647). Hide it while locked so
|
||||
// open right-click menus cannot sit above the lock screen (Codex P2).
|
||||
useEffect(() => {
|
||||
if (!locked) return;
|
||||
const portal = document.getElementById('netcatty-context-menu-root');
|
||||
if (!portal) return;
|
||||
const prev = portal.style.visibility;
|
||||
portal.style.visibility = 'hidden';
|
||||
portal.style.pointerEvents = 'none';
|
||||
return () => {
|
||||
portal.style.visibility = prev;
|
||||
portal.style.pointerEvents = 'none'; // portal default is none; children re-enable
|
||||
};
|
||||
}, [locked]);
|
||||
|
||||
const clearAutoUnlockTimer = useCallback(() => {
|
||||
if (autoUnlockTimerRef.current !== null) {
|
||||
window.clearTimeout(autoUnlockTimerRef.current);
|
||||
autoUnlockTimerRef.current = null;
|
||||
}
|
||||
setIsAutoPromptPending(false);
|
||||
}, []);
|
||||
|
||||
const handleSystemUnlock = useCallback(async () => {
|
||||
if (isSystemUnlocking || !onSystemUnlock) return;
|
||||
clearAutoUnlockTimer();
|
||||
setIsSystemUnlocking(true);
|
||||
setSystemUnlockError(false);
|
||||
try {
|
||||
const result = await onSystemUnlock();
|
||||
if (!result.ok) {
|
||||
setSystemUnlockError(true);
|
||||
setPasswordFallbackRequested(true);
|
||||
}
|
||||
} catch {
|
||||
setSystemUnlockError(true);
|
||||
setPasswordFallbackRequested(true);
|
||||
} finally {
|
||||
setIsSystemUnlocking(false);
|
||||
}
|
||||
}, [clearAutoUnlockTimer, isSystemUnlocking, onSystemUnlock]);
|
||||
|
||||
const requestAutomaticSystemUnlock = useCallback(() => {
|
||||
if (isSystemUnlocking || !onSystemUnlock) return false;
|
||||
void handleSystemUnlock();
|
||||
return true;
|
||||
}, [handleSystemUnlock, isSystemUnlocking, onSystemUnlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locked) return;
|
||||
if (!autoPromptSystemUnlock) return;
|
||||
if (!windowInteractive) return;
|
||||
if (showReset) return;
|
||||
if (isSystemUnlocking) return;
|
||||
if (!systemUnlockStatus?.enabled || !systemUnlockStatus.available || !systemUnlockStatus.label) return;
|
||||
if (!onSystemUnlock) return;
|
||||
|
||||
const presentationKey = reason === 'background'
|
||||
? `background:${reopenSignal}`
|
||||
: `foreground:${reason ?? 'default'}:${locked}`;
|
||||
if (reason === 'background' && reopenSignal <= 0) return;
|
||||
if (
|
||||
passwordFallbackRequested
|
||||
&& lastAutoUnlockPresentationRef.current === presentationKey
|
||||
) return;
|
||||
if (lastAutoUnlockPresentationRef.current === presentationKey) return;
|
||||
if (autoUnlockTimerRef.current !== null) return;
|
||||
|
||||
if (passwordFallbackRequested || systemUnlockError) {
|
||||
setPasswordFallbackRequested(false);
|
||||
setSystemUnlockError(false);
|
||||
}
|
||||
setIsAutoPromptPending(true);
|
||||
const timer = window.setTimeout(() => {
|
||||
if (autoUnlockTimerRef.current !== timer) return;
|
||||
autoUnlockTimerRef.current = null;
|
||||
setIsAutoPromptPending(false);
|
||||
if (!requestAutomaticSystemUnlock()) return;
|
||||
lastAutoUnlockPresentationRef.current = presentationKey;
|
||||
}, APP_LOCK_AUTO_PROMPT_DELAY_MS);
|
||||
autoUnlockTimerRef.current = timer;
|
||||
|
||||
return () => {
|
||||
if (autoUnlockTimerRef.current !== timer) return;
|
||||
window.clearTimeout(timer);
|
||||
autoUnlockTimerRef.current = null;
|
||||
setIsAutoPromptPending(false);
|
||||
};
|
||||
}, [
|
||||
locked,
|
||||
autoPromptSystemUnlock,
|
||||
reason,
|
||||
reopenSignal,
|
||||
windowInteractive,
|
||||
onSystemUnlock,
|
||||
systemUnlockStatus?.available,
|
||||
systemUnlockStatus?.enabled,
|
||||
systemUnlockStatus?.label,
|
||||
passwordFallbackRequested,
|
||||
systemUnlockError,
|
||||
showReset,
|
||||
isSystemUnlocking,
|
||||
requestAutomaticSystemUnlock,
|
||||
]);
|
||||
|
||||
if (!locked) return null;
|
||||
|
||||
const errorKey = getAppLockErrorMessageKey(error);
|
||||
const SystemUnlockIcon = systemUnlockStatus?.platform === 'darwin' ? Fingerprint : KeyRound;
|
||||
const isSystemButtonLoading = isAutoPromptPending || isSystemUnlocking;
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const result = await onUnlock(password);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setPassword('');
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleLogoClick = () => {
|
||||
const now = Date.now();
|
||||
setLogoClickCount((current) => {
|
||||
const isQuickSequence = lastLogoClickAt !== null && now - lastLogoClickAt <= RESET_REVEAL_WINDOW_MS;
|
||||
const nextCount = isQuickSequence ? current + 1 : 1;
|
||||
if (nextCount >= RESET_REVEAL_CLICK_COUNT) {
|
||||
setShowReset(true);
|
||||
}
|
||||
return nextCount;
|
||||
});
|
||||
setLastLogoClickAt(now);
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (isResetting) return;
|
||||
setIsResetting(true);
|
||||
setResetError(false);
|
||||
try {
|
||||
await onResetAppLock(password);
|
||||
} catch {
|
||||
setResetError(true);
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// Overlay covers the title bar, and is the only chrome on a locked first
|
||||
// paint, so the backdrop owns the window drag region.
|
||||
<div
|
||||
ref={overlayRootRef}
|
||||
className="fixed inset-0 flex items-center justify-center bg-background px-6 text-foreground app-drag"
|
||||
style={{ zIndex: 2147483647 }}
|
||||
role="dialog"
|
||||
data-state="open"
|
||||
data-app-lock-overlay=""
|
||||
aria-modal="true"
|
||||
aria-labelledby="app-lock-title"
|
||||
>
|
||||
<form
|
||||
className="flex w-full max-w-[360px] flex-col items-center gap-5 app-no-drag"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-24 w-24 items-center justify-center border-0 bg-transparent p-0 shadow-none transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label={t('appLock.logoLabel')}
|
||||
data-testid="app-lock-logo-easter-egg"
|
||||
onClick={handleLogoClick}
|
||||
>
|
||||
<AppLogo className="h-full w-full" />
|
||||
</button>
|
||||
|
||||
<h1 id="app-lock-title" className="text-center text-xl font-semibold tracking-normal">
|
||||
{t('appLock.title')}
|
||||
</h1>
|
||||
|
||||
{showPasswordUnlock && (
|
||||
<>
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="app-lock-password" className="sr-only">
|
||||
{t('appLock.passwordLabel')}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={passwordRef}
|
||||
id="app-lock-password"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
placeholder={t('appLock.passwordPlaceholder')}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errorKey)}
|
||||
aria-describedby={errorKey ? 'app-lock-error' : undefined}
|
||||
className="pr-12"
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-px right-px flex w-10 items-center justify-center rounded-r-md bg-muted/30 text-muted-foreground"
|
||||
data-testid="app-lock-password-lock-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LockKeyhole className="h-4 w-4" strokeWidth={2.25} />
|
||||
</span>
|
||||
</div>
|
||||
{errorKey && (
|
||||
<p id="app-lock-error" className="text-sm text-destructive">
|
||||
{t(errorKey)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('appLock.unlocking') : t('appLock.unlock')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canUseSystemUnlock && systemUnlockStatus?.label && (
|
||||
<div className="w-full space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={showPasswordUnlock ? 'outline' : 'default'}
|
||||
className="w-full"
|
||||
disabled={isSystemButtonLoading}
|
||||
data-testid="app-lock-system-unlock-button"
|
||||
onClick={() => void handleSystemUnlock()}
|
||||
>
|
||||
{isSystemButtonLoading ? (
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
data-testid="app-lock-system-unlock-loading"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<SystemUnlockIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
data-testid={systemUnlockStatus.platform === 'darwin'
|
||||
? 'app-lock-system-unlock-touch-id-icon'
|
||||
: 'app-lock-system-unlock-windows-icon'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span aria-live="polite">
|
||||
{isAutoPromptPending
|
||||
? t('appLock.systemUnlock.preparing').replace('{label}', systemUnlockStatus.label)
|
||||
: isSystemUnlocking
|
||||
? t('appLock.systemUnlock.verifying').replace('{label}', systemUnlockStatus.label)
|
||||
: t('appLock.systemUnlock.unlockWith').replace('{label}', systemUnlockStatus.label)}
|
||||
</span>
|
||||
</Button>
|
||||
{!showPasswordUnlock && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto w-full py-1 text-muted-foreground"
|
||||
onClick={() => {
|
||||
lastAutoUnlockPresentationRef.current = reason === 'background'
|
||||
? `background:${reopenSignal}`
|
||||
: `foreground:${reason ?? 'default'}:${locked}`;
|
||||
clearAutoUnlockTimer();
|
||||
setPasswordFallbackRequested(true);
|
||||
setSystemUnlockError(false);
|
||||
}}
|
||||
>
|
||||
{t('appLock.usePassword')}
|
||||
</Button>
|
||||
)}
|
||||
{systemUnlockError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t('appLock.systemUnlock.error')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReset && (
|
||||
<div
|
||||
className="w-full rounded-lg border border-border/70 bg-card p-3 text-left shadow-sm"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('appLock.reset.title')}</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{t('appLock.reset.description')}
|
||||
</p>
|
||||
</div>
|
||||
{resetError && (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
{t('appLock.reset.error')}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={isResetting}
|
||||
onClick={() => {
|
||||
setShowReset(false);
|
||||
setLogoClickCount(0);
|
||||
setLastLogoClickAt(null);
|
||||
setResetError(false);
|
||||
}}
|
||||
>
|
||||
{t('appLock.reset.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={isResetting}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{isResetting ? t('appLock.reset.resetting') : t('appLock.reset.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
components/AppLockRouteOwnership.test.ts
Normal file
33
components/AppLockRouteOwnership.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
function readComponentSource(path: string) {
|
||||
return readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("settings, tray, and terminal popup routes do not create fallback settings owners", () => {
|
||||
const settingsPage = readComponentSource("./SettingsPage.tsx");
|
||||
const trayPanel = readComponentSource("./TrayPanel.tsx");
|
||||
const terminalPopupPage = readComponentSource("./TerminalPopupPage.tsx");
|
||||
|
||||
assert.doesNotMatch(settingsPage, /fallbackSettings\s*=\s*useSettingsState\(\)/);
|
||||
assert.doesNotMatch(settingsPage, /settings\?:\s*SettingsState/);
|
||||
assert.doesNotMatch(settingsPage, /providedSettings\s*\?\?/);
|
||||
|
||||
assert.doesNotMatch(trayPanel, /fallbackSettings\s*=\s*useSettingsState\(\)/);
|
||||
assert.doesNotMatch(trayPanel, /settings\?:\s*SettingsState/);
|
||||
assert.doesNotMatch(trayPanel, /providedSettings\s*\?\?/);
|
||||
|
||||
assert.doesNotMatch(terminalPopupPage, /fallbackSettings\s*=\s*useSettingsState\(\)/);
|
||||
assert.doesNotMatch(terminalPopupPage, /settings\?:\s*SettingsState/);
|
||||
assert.doesNotMatch(terminalPopupPage, /providedSettings\s*\?\?/);
|
||||
});
|
||||
|
||||
test("terminal popups wait for app-lock initialization before starting a terminal", () => {
|
||||
const indexSource = readComponentSource("../index.tsx");
|
||||
assert.match(
|
||||
indexSource,
|
||||
/allowTerminalStart=\{appLock\.initialized\s*&&\s*!appLock\.locked\}/,
|
||||
);
|
||||
});
|
||||
29
components/AppLogo.tsx
Normal file
29
components/AppLogo.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppLogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NetMesh app icon — rounded tile with primary-color background and the elf SVG.
|
||||
* This replaces the old hand-drawn netcatty logo so every surface (vault sidebar,
|
||||
* tray icon, lock overlay, traffic diagram, etc.) renders the same elf + theme tile.
|
||||
*/
|
||||
export const AppLogo: React.FC<AppLogoProps> = ({ className }) => (
|
||||
<div
|
||||
className={`relative flex items-center justify-center overflow-hidden rounded-[22%] ${className || ''}`}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.85) 100%)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/senmesh-elf-logo.svg"
|
||||
alt=""
|
||||
className="w-full h-full object-contain drop-shadow-sm"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AppLogo;
|
||||
25
components/AppWordmark.test.tsx
Normal file
25
components/AppWordmark.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AppWordmark } from './AppWordmark';
|
||||
|
||||
test('AppWordmark renders decorative fixed vector outlines without font-dependent text', () => {
|
||||
const markup = renderToStaticMarkup(<AppWordmark className="h-5" />);
|
||||
|
||||
assert.match(markup, /<path /);
|
||||
assert.match(markup, /aria-hidden="true"/);
|
||||
assert.doesNotMatch(markup, /<text/);
|
||||
assert.doesNotMatch(markup, /font-family/);
|
||||
assert.match(markup, /class="h-5"/);
|
||||
});
|
||||
|
||||
test('AppWordmark exposes an accessible product name when requested', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AppWordmark accessibleLabel="Netcatty" className="h-8" />,
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Netcatty"/);
|
||||
assert.match(markup, /role="img"/);
|
||||
assert.doesNotMatch(markup, /aria-hidden/);
|
||||
});
|
||||
20
components/AppWordmark.tsx
Normal file
20
components/AppWordmark.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppWordmarkProps {
|
||||
accessibleLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** NetMesh brand wordmark — just the "NetMesh" text. */
|
||||
export const AppWordmark: React.FC<AppWordmarkProps> = ({ accessibleLabel, className }) => (
|
||||
<span
|
||||
className={`font-bold tracking-tight text-foreground ${className || ''}`}
|
||||
aria-label={accessibleLabel}
|
||||
role={accessibleLabel ? 'img' : undefined}
|
||||
style={{ fontFamily: 'inherit', fontWeight: 700, letterSpacing: '-0.02em' }}
|
||||
>
|
||||
NetMesh
|
||||
</span>
|
||||
);
|
||||
|
||||
export default AppWordmark;
|
||||
25
components/CloudSyncSettings.test.ts
Normal file
25
components/CloudSyncSettings.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("USE_LOCAL conflict resolution forces upload-local without decrypting remote", () => {
|
||||
const source = readFileSync(new URL("./CloudSyncSettings.tsx", import.meta.url), "utf8");
|
||||
const useLocalIndex = source.indexOf("} else if (resolution === 'USE_LOCAL') {");
|
||||
const syncNowIndex = source.indexOf("results = await sync.syncNow(localPayload, {", useLocalIndex);
|
||||
const overrideShrinkIndex = source.indexOf("overrideShrink: true,", syncNowIndex);
|
||||
const uploadLocalIndex = source.indexOf("conflictActionOverride: 'upload-local',", syncNowIndex);
|
||||
const nextBranchIndex = source.indexOf("toast.success(t('cloudSync.resolve.uploaded'));", syncNowIndex);
|
||||
|
||||
assert.notEqual(useLocalIndex, -1);
|
||||
assert.notEqual(syncNowIndex, -1);
|
||||
assert.notEqual(overrideShrinkIndex, -1);
|
||||
assert.notEqual(uploadLocalIndex, -1);
|
||||
assert.notEqual(nextBranchIndex, -1);
|
||||
assert.ok(
|
||||
useLocalIndex < syncNowIndex
|
||||
&& syncNowIndex < overrideShrinkIndex
|
||||
&& overrideShrinkIndex < uploadLocalIndex
|
||||
&& uploadLocalIndex < nextBranchIndex,
|
||||
"keep-local must re-sync with conflictActionOverride upload-local so a new master password can overwrite an undecryptable cloud backup",
|
||||
);
|
||||
});
|
||||
1090
components/CloudSyncSettings.tsx
Normal file
1090
components/CloudSyncSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
64
components/ConnectionLogsManager.test.tsx
Normal file
64
components/ConnectionLogsManager.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { ConnectionLog } from "../types.ts";
|
||||
import ConnectionLogsManager from "./ConnectionLogsManager.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
id: "log-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Database",
|
||||
hostname: "db.example.com",
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
hostOs: "linux",
|
||||
hostDistro: "ubuntu",
|
||||
startTime: 1_700_000_000_000,
|
||||
localUsername: "alice",
|
||||
localHostname: "workstation",
|
||||
saved: false,
|
||||
};
|
||||
|
||||
const renderLogs = (log: ConnectionLog) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ConnectionLogsManager
|
||||
logs={[log]}
|
||||
hosts={[]}
|
||||
onToggleSaved={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClearUnsaved={() => {}}
|
||||
onOpenLogView={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("ConnectionLogsManager renders saved custom host icon snapshots", () => {
|
||||
const markup = renderLogs({
|
||||
...baseLog,
|
||||
hostIconMode: "custom",
|
||||
hostIconId: "database",
|
||||
hostIconColor: "blue",
|
||||
});
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("ConnectionLogsManager renders saved distro icon snapshots with custom colors", () => {
|
||||
const markup = renderLogs({
|
||||
...baseLog,
|
||||
hostIconMode: "auto",
|
||||
hostIconColor: "violet",
|
||||
});
|
||||
|
||||
assert.match(markup, /background-color:#7C3AED/i);
|
||||
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
296
components/ConnectionLogsManager.tsx
Normal file
296
components/ConnectionLogsManager.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
Bookmark,
|
||||
ChevronDown,
|
||||
CircleUserRound,
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Usb,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { resolveHostIconAppearance } from "../domain/hostIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
interface ConnectionLogsManagerProps {
|
||||
logs: ConnectionLog[];
|
||||
hosts: Host[];
|
||||
onToggleSaved: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClearUnsaved: () => void;
|
||||
onOpenLogView: (log: ConnectionLog) => void;
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (timestamp: number, locale: string) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString(locale || undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Format time range
|
||||
const formatTimeRange = (start: number, end: number | undefined, locale: string, ongoingLabel: string) => {
|
||||
const startDate = new Date(start);
|
||||
const startTime = startDate.toLocaleTimeString(locale || undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
if (!end) {
|
||||
return `${startTime} - ${ongoingLabel}`;
|
||||
}
|
||||
|
||||
const endDate = new Date(end);
|
||||
const endTime = endDate.toLocaleTimeString(locale || undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return `${startTime} - ${endTime}`;
|
||||
};
|
||||
|
||||
// Log Item Component
|
||||
interface LogItemProps {
|
||||
log: ConnectionLog;
|
||||
onToggleSaved: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
const customHostIcon = resolveHostIconAppearance({
|
||||
iconMode: log.hostIconMode,
|
||||
iconId: log.hostIconId,
|
||||
iconColorMode: log.hostIconColorMode,
|
||||
iconColor: log.hostIconColor,
|
||||
iconColorCustom: log.hostIconColorCustom,
|
||||
});
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && (!!log.hostDistro || !!customHostIcon);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center gap-4 px-4 py-3 hover:bg-secondary/60 transition-colors border-b border-border/30 last:border-b-0 cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Date column */}
|
||||
<div className="w-32 shrink-0">
|
||||
<div className="text-sm font-medium">{formatDate(log.startTime, resolvedLocale)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatTimeRange(log.startTime, log.endTime, resolvedLocale, t("logs.ongoing"))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User column */}
|
||||
<div className="flex items-center gap-2 w-56 shrink-0">
|
||||
<div className="h-9 w-9 rounded-xl bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center shrink-0">
|
||||
<CircleUserRound size={18} strokeWidth={2.25} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{log.localUsername}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{log.localHostname}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host column */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{hasPersistedHostIcon ? (
|
||||
<DistroAvatar
|
||||
host={{
|
||||
os: log.hostOs ?? "linux",
|
||||
distro: log.hostDistro,
|
||||
distroMode: "auto",
|
||||
iconMode: log.hostIconMode,
|
||||
iconId: log.hostIconId,
|
||||
iconColorMode: log.hostIconColorMode,
|
||||
iconColor: log.hostIconColor,
|
||||
iconColorCustom: log.hostIconColorCustom,
|
||||
}}
|
||||
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
|
||||
size="log"
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"h-9 w-9 rounded-xl flex items-center justify-center shrink-0",
|
||||
isSerial
|
||||
? "bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950"
|
||||
: isLocal
|
||||
? "bg-slate-600 text-white dark:bg-slate-300 dark:text-slate-950"
|
||||
: "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
{isSerial ? <Usb size={17} /> : isLocal ? <Terminal size={17} /> : <Server size={17} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved column */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved(log.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
log.saved
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(log.id);
|
||||
}}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.action.delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LogItem.displayName = "LogItem";
|
||||
|
||||
const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
||||
logs,
|
||||
hosts: _hosts,
|
||||
onToggleSaved,
|
||||
onDelete,
|
||||
onClearUnsaved: _onClearUnsaved,
|
||||
onOpenLogView,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const INITIAL_RENDER_LIMIT = 30;
|
||||
const LOAD_MORE_COUNT = 30;
|
||||
|
||||
// Track how many items to show
|
||||
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER_LIMIT);
|
||||
|
||||
// Sort logs by newest first
|
||||
const filteredLogs = useMemo(() => {
|
||||
return [...logs].sort((a, b) => b.startTime - a.startTime);
|
||||
}, [logs]);
|
||||
|
||||
const displayedLogs = useMemo(() => {
|
||||
return filteredLogs.slice(0, renderLimit);
|
||||
}, [filteredLogs, renderLimit]);
|
||||
|
||||
const hasMore = filteredLogs.length > renderLimit;
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setRenderLimit(prev => prev + LOAD_MORE_COUNT);
|
||||
}, []);
|
||||
|
||||
const handleToggleSaved = useCallback(
|
||||
(id: string) => onToggleSaved(id),
|
||||
[onToggleSaved],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => onDelete(id),
|
||||
[onDelete],
|
||||
);
|
||||
|
||||
// Rendered items
|
||||
const renderedItems = useMemo(() => {
|
||||
return displayedLogs.map((log) => (
|
||||
<LogItem
|
||||
key={log.id}
|
||||
log={log}
|
||||
onToggleSaved={handleToggleSaved}
|
||||
onDelete={handleDelete}
|
||||
onClick={() => onOpenLogView(log)}
|
||||
/>
|
||||
));
|
||||
}, [displayedLogs, handleToggleSaved, handleDelete, onOpenLogView]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Table Header */}
|
||||
{displayedLogs.length > 0 && (
|
||||
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-muted-foreground border-b border-border/30 bg-secondary/30">
|
||||
<div className="w-32 shrink-0 flex items-center gap-1">
|
||||
{t("logs.table.date")}
|
||||
<ChevronDown size={12} />
|
||||
</div>
|
||||
<div className="w-56 shrink-0">{t("logs.table.user")}</div>
|
||||
<div className="flex-1">{t("logs.table.host")}</div>
|
||||
<div className="w-20 shrink-0 flex items-center gap-1">
|
||||
{t("logs.table.saved")}
|
||||
<Bookmark size={12} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div>
|
||||
{displayedLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Terminal size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("logs.empty.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
{t("logs.empty.desc")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{renderedItems}
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="w-full py-3 text-sm text-primary hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
{t("logs.loadMore", { count: Math.min(LOAD_MORE_COUNT, filteredLogs.length - renderLimit) })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom comparison
|
||||
const logsManagerAreEqual = (
|
||||
prev: ConnectionLogsManagerProps,
|
||||
next: ConnectionLogsManagerProps,
|
||||
): boolean => {
|
||||
return prev.logs === next.logs && prev.hosts === next.hosts;
|
||||
};
|
||||
|
||||
export default memo(ConnectionLogsManager, logsManagerAreEqual);
|
||||
150
components/CreateWorkspaceDialog.tsx
Normal file
150
components/CreateWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { Host } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
|
||||
|
||||
const CREATE_WS_HOST_ROW_HEIGHT = 52;
|
||||
|
||||
interface CreateWorkspaceDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
hosts: Host[];
|
||||
onCreate: (name: string, selectedHosts: Host[]) => void;
|
||||
}
|
||||
|
||||
export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
hosts,
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!search.trim()) return hosts;
|
||||
const term = search.toLowerCase();
|
||||
return hosts.filter(h =>
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
);
|
||||
}, [hosts, search]);
|
||||
|
||||
const toggleHost = useCallback((hostId: string) => {
|
||||
setSelectedHostIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hostId)) {
|
||||
next.delete(hostId);
|
||||
} else {
|
||||
next.add(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
const selected = hosts.filter(h => selectedHostIds.has(h.id));
|
||||
onCreate(name, selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setSearch('');
|
||||
setSelectedHostIds(new Set());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const renderHostRow = useCallback((host: Host) => {
|
||||
const isSelected = selectedHostIds.has(host.id);
|
||||
return (
|
||||
<div
|
||||
data-host-id={host.id}
|
||||
className={`flex h-full items-center gap-3 px-2 rounded-md cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-primary/10' : ''}`}
|
||||
onClick={() => toggleHost(host.id)}
|
||||
>
|
||||
<div className={`h-4 w-4 border rounded flex items-center justify-center ${isSelected ? 'bg-primary border-primary' : 'border-muted-foreground'}`}>
|
||||
{isSelected && <div className="h-2 w-2 bg-primary-foreground rounded-sm" />}
|
||||
</div>
|
||||
<DistroAvatar host={host} size="sm" fallback={host.label.slice(0, 2).toUpperCase()} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [selectedHostIds, toggleHost]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.createWorkspace.title', { defaultValue: 'Create Workspace' })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name', { defaultValue: 'Name' })}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('placeholder.workspaceName', { defaultValue: 'Workspace Name' })}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 flex flex-col min-h-0">
|
||||
<Label>{t('field.selectHosts', { defaultValue: 'Select Hosts' })}</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md h-[300px] min-h-[200px]" data-host-picker-virtual="create-workspace">
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t('common.noResults', { defaultValue: 'No hosts found' })}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
items={filteredHosts}
|
||||
itemHeight={CREATE_WS_HOST_ROW_HEIGHT}
|
||||
className="h-full"
|
||||
overscan={8}
|
||||
getItemKey={(host) => host.id}
|
||||
renderItem={renderHostRow}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{selectedHostIds.size} {t('common.selected', { defaultValue: 'selected' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
|
||||
{t('common.create', { defaultValue: 'Create' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
104
components/DistroAvatar.test.tsx
Normal file
104
components/DistroAvatar.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { DistroAvatar } from "./DistroAvatar.tsx";
|
||||
import { LINUX_DISTRO_OPTION_IDS } from "./HostDetailsPanel.helpers.ts";
|
||||
import type { Host } from "../types.ts";
|
||||
|
||||
const baseHost: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os" | "protocol" | "iconMode" | "iconId" | "iconColor"> = {
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
test("DistroAvatar renders custom host icon before distro color", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu", iconMode: "custom", iconId: "database", iconColor: "blue" }}
|
||||
fallback="DB"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps serial hosts on the USB icon", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, protocol: "serial", iconMode: "custom", iconId: "database", iconColor: "blue" }}
|
||||
fallback="S"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /bg-amber-600/);
|
||||
assert.doesNotMatch(markup, /background-color:#2563EB/i);
|
||||
});
|
||||
|
||||
test("DistroAvatar xs size uses compact corners without appearing circular", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu" }}
|
||||
fallback="U"
|
||||
size="xs"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /h-4 w-4 rounded-\[4px\]/);
|
||||
assert.doesNotMatch(markup, /rounded-full/);
|
||||
});
|
||||
|
||||
test("DistroAvatar tree size uses compact host icon corners", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu" }}
|
||||
fallback="U"
|
||||
size="tree"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /h-6 w-6 rounded-\[6px\]/);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu", iconMode: "auto", iconId: "database", iconColor: "blue" }}
|
||||
fallback="U"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("DistroAvatar renders H3C logo without monochrome inversion", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "h3c" }}
|
||||
fallback="H"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /src="\/distro\/h3c.svg"/);
|
||||
assert.match(markup, /bg-white/);
|
||||
assert.doesNotMatch(markup, /invert brightness-0/);
|
||||
});
|
||||
|
||||
test("DistroAvatar renders the FreeBSD logo for a detected FreeBSD host", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ distro: "freebsd", os: "linux" }}
|
||||
fallback="FB"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /src="\/distro\/freebsd\.svg"/);
|
||||
assert.match(markup, /alt="freebsd"/);
|
||||
});
|
||||
|
||||
test("FreeBSD is available as a manual host icon choice", () => {
|
||||
assert.ok(LINUX_DISTRO_OPTION_IDS.includes("freebsd"));
|
||||
});
|
||||
196
components/DistroAvatar.tsx
Normal file
196
components/DistroAvatar.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { getEffectiveHostDistro } from "../domain/host";
|
||||
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from "../domain/hostIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host } from "../types";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
|
||||
export const DISTRO_LOGOS: Record<string, string> = {
|
||||
ubuntu: "/distro/ubuntu.svg",
|
||||
debian: "/distro/debian.svg",
|
||||
centos: "/distro/centos.svg",
|
||||
rocky: "/distro/rocky.svg",
|
||||
fedora: "/distro/fedora.svg",
|
||||
arch: "/distro/arch.svg",
|
||||
alpine: "/distro/alpine.svg",
|
||||
amazon: "/distro/amazon.svg",
|
||||
opensuse: "/distro/opensuse.svg",
|
||||
redhat: "/distro/redhat.svg",
|
||||
oracle: "/distro/oracle.svg",
|
||||
kali: "/distro/kali.svg",
|
||||
almalinux: "/distro/almalinux.svg",
|
||||
alinux: "/distro/alinux.svg",
|
||||
openeuler: "/distro/openeuler.svg",
|
||||
// OS-level logos (used by local terminal tab icons)
|
||||
macos: "/distro/macos.svg",
|
||||
freebsd: "/distro/freebsd.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
linux: "/distro/linux.svg",
|
||||
// Network device vendors — auto-detected from the SSH server
|
||||
// identification string (see domain/host.ts `detectVendorFromSshVersion`).
|
||||
cisco: "/distro/cisco.svg",
|
||||
juniper: "/distro/juniper.svg",
|
||||
huawei: "/distro/huawei.svg",
|
||||
h3c: "/distro/h3c.svg",
|
||||
hpe: "/distro/hpe.svg",
|
||||
mikrotik: "/distro/mikrotik.svg",
|
||||
fortinet: "/distro/fortinet.svg",
|
||||
paloalto: "/distro/paloalto.svg",
|
||||
zyxel: "/distro/zyxel.svg",
|
||||
};
|
||||
|
||||
export const DISTRO_COLORS: Record<string, string> = {
|
||||
ubuntu: "bg-[#E95420]",
|
||||
debian: "bg-[#A81D33]",
|
||||
centos: "bg-[#9C27B0]",
|
||||
rocky: "bg-[#0B9B69]",
|
||||
fedora: "bg-[#3C6EB4]",
|
||||
arch: "bg-[#1793D1]",
|
||||
alpine: "bg-[#0D597F]",
|
||||
amazon: "bg-[#FF9900]",
|
||||
opensuse: "bg-[#73BA25]",
|
||||
redhat: "bg-[#EE0000]",
|
||||
oracle: "bg-[#C74634]",
|
||||
kali: "bg-[#0F6DB3]",
|
||||
almalinux: "bg-[#173B66]",
|
||||
alinux: "bg-[#FF6A00]",
|
||||
openeuler: "bg-[#002FA7]",
|
||||
// OS-level colors
|
||||
macos: "bg-[#333333]",
|
||||
freebsd: "bg-[#AB2B28]",
|
||||
windows: "bg-[#0078D4]",
|
||||
linux: "bg-[#333333]",
|
||||
// Network device vendor brand colors
|
||||
cisco: "bg-[#1BA0D7]",
|
||||
juniper: "bg-[#0A6EB4]",
|
||||
huawei: "bg-[#CF0A2C]",
|
||||
h3c: "bg-white",
|
||||
hpe: "bg-[#01A982]",
|
||||
mikrotik: "bg-[#293239]",
|
||||
fortinet: "bg-[#EE3124]",
|
||||
paloalto: "bg-[#FA582D]",
|
||||
zyxel: "bg-[#00497A]",
|
||||
ruijie: "bg-[#E60012]",
|
||||
default: "bg-slate-600",
|
||||
};
|
||||
|
||||
type DistroAvatarProps = {
|
||||
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
|
||||
Partial<Pick<Host, "protocol" | "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom">>;
|
||||
fallback: string;
|
||||
className?: string;
|
||||
/** xs matches top tab bar icons (h-4 rounded rect) */
|
||||
size?: "xs" | "sm" | "md" | "tree" | "log" | "lg";
|
||||
};
|
||||
|
||||
const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
host,
|
||||
fallback: _fallback,
|
||||
className,
|
||||
size = "md",
|
||||
}) => {
|
||||
const distro = getEffectiveHostDistro(host);
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
const [errored, setErrored] = React.useState(false);
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
|
||||
// Corner radii ~27% of edge (matches lg rounded-xl on 44px) — avoids circles on small sizes
|
||||
const radiusClasses = {
|
||||
xs: "rounded-[4px]",
|
||||
sm: "rounded-[5px]",
|
||||
md: "rounded-lg",
|
||||
tree: "rounded-[6px]",
|
||||
log: "rounded-[10px]",
|
||||
lg: "rounded-xl",
|
||||
} as const;
|
||||
|
||||
// Size variants — rounded rects aligned with vault host list (lg = rounded-xl)
|
||||
const sizeClasses = {
|
||||
xs: "h-4 w-4",
|
||||
sm: "h-5 w-5",
|
||||
md: "h-8 w-8",
|
||||
tree: "h-6 w-6",
|
||||
log: "h-9 w-9",
|
||||
lg: "h-11 w-11",
|
||||
};
|
||||
const iconSizes = {
|
||||
xs: "h-2.5 w-2.5",
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
tree: "h-3.5 w-3.5",
|
||||
log: "h-5 w-5",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
const containerClass = cn(sizeClasses[size], radiusClasses[size]);
|
||||
const iconSize = iconSizes[size];
|
||||
|
||||
// Show USB icon for serial hosts
|
||||
if (host.protocol === 'serial') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Usb className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const customAppearance = resolveHostIconAppearance(host);
|
||||
const customColor = resolveHostIconColorAppearance(host);
|
||||
if (customAppearance) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center text-white",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: customAppearance.colorHex }}
|
||||
>
|
||||
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && !errored) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center overflow-hidden",
|
||||
containerClass,
|
||||
!customColor && bg,
|
||||
className,
|
||||
)}
|
||||
style={customColor ? { backgroundColor: customColor.colorHex } : undefined}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt={distro || host.os}
|
||||
className={distro === "h3c" ? "object-contain w-[80%]" : cn("object-contain invert brightness-0", iconSize)}
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center bg-primary text-primary-foreground",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Server className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DistroAvatar = memo(DistroAvatarInner);
|
||||
DistroAvatar.displayName = "DistroAvatar";
|
||||
135
components/FileOpenerDialog.tsx
Normal file
135
components/FileOpenerDialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* FileOpenerDialog - Dialog for choosing how to open a file
|
||||
*/
|
||||
import { Edit2, FolderOpen } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
|
||||
import { getFileExtension, hasFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface FileOpenerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
fileName: string;
|
||||
onSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
onSelect,
|
||||
onSelectSystemApp,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isSelectingApp, setIsSelectingApp] = useState(false);
|
||||
const [rememberChoice, setRememberChoice] = useState(() => hasFileExtension(fileName));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRememberChoice(hasFileExtension(fileName));
|
||||
}
|
||||
}, [open, fileName]);
|
||||
|
||||
const extension = getFileExtension(fileName);
|
||||
// Show edit option for files that are not known binary formats
|
||||
const canEdit = !isKnownBinaryFile(fileName);
|
||||
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
|
||||
|
||||
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {
|
||||
onSelect(openerType, rememberChoice);
|
||||
onClose();
|
||||
}, [rememberChoice, onSelect, onClose]);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async () => {
|
||||
setIsSelectingApp(true);
|
||||
try {
|
||||
const result = await onSelectSystemApp();
|
||||
if (result) {
|
||||
onSelect('system-app', rememberChoice, result);
|
||||
onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setIsSelectingApp(false);
|
||||
}
|
||||
}, [onSelectSystemApp, rememberChoice, onSelect, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
// Don't close while selecting system app
|
||||
if (!isOpen && !isSelectingApp) {
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle>{t('sftp.opener.title')}</DialogTitle>
|
||||
<DialogDescription className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{fileName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-2">
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-12"
|
||||
onClick={() => handleSelectBuiltIn('builtin-editor')}
|
||||
>
|
||||
<Edit2 size={18} className="text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">{t('sftp.opener.builtInEditor')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('sftp.opener.editDescription')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* System application option */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-12"
|
||||
onClick={handleSelectSystemApp}
|
||||
disabled={isSelectingApp}
|
||||
>
|
||||
<FolderOpen size={18} className="text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">{t('sftp.opener.systemApp')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('sftp.opener.systemAppDescription')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Remember choice checkbox - always show, use 'file' for no extension */}
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember-choice"
|
||||
checked={rememberChoice}
|
||||
onChange={(e) => setRememberChoice(e.target.checked)}
|
||||
className="rounded border-border h-4 w-4 accent-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember-choice"
|
||||
className="text-sm text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t('sftp.opener.setDefault', { ext: displayExtension })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileOpenerDialog;
|
||||
118
components/GlobalSftpTransferCenter.accessibility.test.ts
Normal file
118
components/GlobalSftpTransferCenter.accessibility.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
createDomRenderer,
|
||||
dispatchDomEvent,
|
||||
flushEffects,
|
||||
installDomEnvironment,
|
||||
} from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("associates the transfer-center folder warning with every destructive Replace action", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previousMutationObserver = globalThis.MutationObserver;
|
||||
const previousNodeFilter = globalThis.NodeFilter;
|
||||
const previousHTMLInputElement = globalThis.HTMLInputElement;
|
||||
const previousElement = globalThis.Element;
|
||||
const previousRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const previousCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
Object.defineProperties(globalThis, {
|
||||
MutationObserver: { configurable: true, writable: true, value: env.window.MutationObserver },
|
||||
NodeFilter: { configurable: true, writable: true, value: env.window.NodeFilter },
|
||||
HTMLInputElement: { configurable: true, writable: true, value: env.window.HTMLInputElement },
|
||||
Element: { configurable: true, writable: true, value: env.window.Element },
|
||||
requestAnimationFrame: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
},
|
||||
cancelAnimationFrame: { configurable: true, writable: true, value: clearTimeout },
|
||||
});
|
||||
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { sftpTransferCenterStore } = await import("../application/state/sftpTransferCenterStore.ts");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const taskId = "accessible-folder-conflict";
|
||||
const unregisterOwner = sftpTransferCenterStore.registerOwner("accessibility-test-owner", {
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
cancel: () => {},
|
||||
retry: () => {},
|
||||
prioritize: () => {},
|
||||
dismiss: () => {},
|
||||
ownsTask: () => true,
|
||||
resolveConflict: () => {},
|
||||
});
|
||||
sftpTransferCenterStore.publishOwner("accessibility-test-owner", [{
|
||||
id: taskId,
|
||||
fileName: "docs",
|
||||
sourcePath: "/source/docs",
|
||||
targetPath: "/destination/docs",
|
||||
sourceConnectionId: "source",
|
||||
targetConnectionId: "target",
|
||||
direction: "remote-to-remote",
|
||||
status: "attention",
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: 1,
|
||||
isDirectory: true,
|
||||
conflict: {
|
||||
transferId: taskId,
|
||||
fileName: "docs",
|
||||
sourcePath: "/source/docs",
|
||||
targetPath: "/destination/docs",
|
||||
isDirectory: true,
|
||||
existingType: "directory",
|
||||
applyToAllCount: 2,
|
||||
existingSize: 0,
|
||||
newSize: 0,
|
||||
existingModified: 1,
|
||||
newModified: 2,
|
||||
},
|
||||
}]);
|
||||
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
sftpTransferCenterStore.dismiss(taskId);
|
||||
unregisterOwner();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
Object.defineProperties(globalThis, {
|
||||
MutationObserver: { configurable: true, writable: true, value: previousMutationObserver },
|
||||
NodeFilter: { configurable: true, writable: true, value: previousNodeFilter },
|
||||
HTMLInputElement: { configurable: true, writable: true, value: previousHTMLInputElement },
|
||||
Element: { configurable: true, writable: true, value: previousElement },
|
||||
requestAnimationFrame: { configurable: true, writable: true, value: previousRequestAnimationFrame },
|
||||
cancelAnimationFrame: { configurable: true, writable: true, value: previousCancelAnimationFrame },
|
||||
});
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
await renderer.render(React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(TooltipProvider, null, React.createElement(GlobalSftpTransferCenter)),
|
||||
));
|
||||
await flushEffects();
|
||||
|
||||
const toggle = env.document.querySelector<HTMLButtonElement>("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.ok(toggle, "transfer-center toggle should render");
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
const replaceButtons = Array.from(env.document.querySelectorAll<HTMLButtonElement>("button"))
|
||||
.filter((button) => button.textContent?.startsWith("Replace"));
|
||||
assert.equal(replaceButtons.length, 2);
|
||||
for (const button of replaceButtons) {
|
||||
const describedBy = button.getAttribute("aria-describedby");
|
||||
assert.ok(describedBy, `${button.textContent} should expose the folder deletion warning`);
|
||||
const warning = env.document.getElementById(describedBy);
|
||||
assert.match(
|
||||
warning?.textContent ?? "",
|
||||
/Replace: deletes destination-only content and cannot be undone/,
|
||||
);
|
||||
}
|
||||
});
|
||||
69
components/GlobalSftpTransferCenter.resume-status.test.tsx
Normal file
69
components/GlobalSftpTransferCenter.resume-status.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("resume from another panel is visible while backend verification is pending", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
NodeFilter: env.window.NodeFilter,
|
||||
HTMLInputElement: env.window.HTMLInputElement,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
Object.defineProperties(env.window.HTMLElement.prototype, {
|
||||
offsetWidth: { configurable: true, get: () => 460 },
|
||||
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
|
||||
});
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
let settleResume!: (result: { success: boolean }) => void;
|
||||
let resumeCalls = 0;
|
||||
const gate = new Promise<{ success: boolean }>((resolve) => { settleResume = resolve; });
|
||||
Object.defineProperty(env.window, "netcatty", { configurable: true, value: {
|
||||
resumeTransfer: async () => { resumeCalls += 1; return gate; },
|
||||
} });
|
||||
store.publishOwner("resume-display-test", [{
|
||||
id: "resume-display", fileName: "resume-display.bin",
|
||||
sourcePath: "/source.bin", targetPath: "/target.bin",
|
||||
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
|
||||
status: "paused", totalBytes: 1000, transferredBytes: 100, speed: 0,
|
||||
startTime: 1, isDirectory: false, resumable: true,
|
||||
}]);
|
||||
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
|
||||
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.ok(toggle);
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
let resumed!: Promise<void>;
|
||||
await act(async () => { resumed = store.resume("resume-display"); });
|
||||
try {
|
||||
await flushEffects();
|
||||
assert.equal(resumeCalls, 1);
|
||||
const row = env.document.querySelector('[role=progressbar][aria-label="resume-display.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(row);
|
||||
assert.match(row.textContent ?? "", /Reconnecting and resuming/);
|
||||
assert.equal(row.querySelector('button[aria-label="Resume"]'), null);
|
||||
} finally {
|
||||
await act(async () => { settleResume({ success: true }); await resumed; });
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
}
|
||||
});
|
||||
241
components/GlobalSftpTransferCenter.test.ts
Normal file
241
components/GlobalSftpTransferCenter.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { TransferTask } from "../domain/models";
|
||||
import {
|
||||
buildGlobalTransferProgressDisplay,
|
||||
getGlobalConflictActionPresentation,
|
||||
getGlobalTransferBadge,
|
||||
getGlobalTransferBatchEligibility,
|
||||
getGlobalTransferStatusOverride,
|
||||
getGlobalTransferBucket,
|
||||
getTasksForGlobalTransferBucket,
|
||||
isDirectoryParentTask,
|
||||
listChildTasksForParent,
|
||||
pickActiveChildSummaries,
|
||||
shouldShowCollapsedActiveChildren,
|
||||
splitBackgroundTransfers,
|
||||
} from "./GlobalSftpTransferCenter";
|
||||
|
||||
test("folder conflicts emphasize merge and mark replace as destructive in the transfer center", () => {
|
||||
assert.deepEqual(getGlobalConflictActionPresentation("merge", true), {
|
||||
variant: "default",
|
||||
destructiveReplace: false,
|
||||
});
|
||||
assert.deepEqual(getGlobalConflictActionPresentation("replace", true), {
|
||||
variant: "outline",
|
||||
destructiveReplace: true,
|
||||
});
|
||||
assert.deepEqual(getGlobalConflictActionPresentation("replace", false), {
|
||||
variant: "default",
|
||||
destructiveReplace: false,
|
||||
});
|
||||
});
|
||||
|
||||
const task = (id: string, status: TransferTask["status"], background = false) => ({
|
||||
id,
|
||||
fileName: id,
|
||||
sourcePath: "/a",
|
||||
targetPath: "/b",
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload" as const,
|
||||
status,
|
||||
totalBytes: 10,
|
||||
transferredBytes: 1,
|
||||
speed: 1,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
background,
|
||||
});
|
||||
|
||||
test("global transfer statuses keep paused work separate from items needing attention", () => {
|
||||
assert.equal(getGlobalTransferBucket(task("a", "transferring")), "active");
|
||||
assert.equal(getGlobalTransferBucket(task("a", "pausing")), "active");
|
||||
assert.equal(getGlobalTransferBucket(task("a", "queued")), "queued");
|
||||
assert.equal(getGlobalTransferBucket(task("a", "paused")), "paused");
|
||||
assert.equal(getGlobalTransferBucket(task("a", "interrupted")), "attention");
|
||||
assert.equal(getGlobalTransferBucket(task("a", "attention")), "attention");
|
||||
assert.equal(getGlobalTransferBucket(task("a", "failed")), "attention");
|
||||
assert.equal(getGlobalTransferBucket(task("a", "completed")), "completed");
|
||||
assert.equal(getGlobalTransferBucket(task("a", "cancelled")), "completed");
|
||||
});
|
||||
|
||||
test("batch actions are disabled when no task can accept them", () => {
|
||||
assert.deepEqual(getGlobalTransferBatchEligibility([
|
||||
task("done", "completed"),
|
||||
task("cancelled", "cancelled"),
|
||||
]), { pausableCount: 0, resumableCount: 0 });
|
||||
|
||||
assert.deepEqual(getGlobalTransferBatchEligibility([
|
||||
task("running", "transferring"),
|
||||
task("waiting", "queued"),
|
||||
task("paused", "paused"),
|
||||
{ ...task("failed", "failed"), checkpointBytes: 4 },
|
||||
]), { pausableCount: 2, resumableCount: 2 });
|
||||
});
|
||||
|
||||
test("paused transfer errors remain visible instead of looking safely paused", () => {
|
||||
assert.equal(getGlobalTransferStatusOverride({
|
||||
error: "Upload resume is unavailable",
|
||||
pauseUnavailableReason: undefined,
|
||||
}), "Upload resume is unavailable");
|
||||
});
|
||||
|
||||
test("the all bucket includes every top-level task regardless of status", () => {
|
||||
const tasks = [
|
||||
task("a", "transferring"),
|
||||
task("b", "queued"),
|
||||
task("c", "failed"),
|
||||
{ ...task("child", "transferring"), parentTaskId: "a" },
|
||||
];
|
||||
assert.deepEqual(getTasksForGlobalTransferBucket(tasks, "all").map((item) => item.id), ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("badge counts active and queued work while surfacing attention", () => {
|
||||
assert.deepEqual(getGlobalTransferBadge([
|
||||
task("a", "transferring"),
|
||||
task("b", "queued"),
|
||||
task("c", "failed"),
|
||||
]), { count: 2, hasAttention: true });
|
||||
});
|
||||
|
||||
test("badge does not double count child files in a folder transfer", () => {
|
||||
assert.deepEqual(getGlobalTransferBadge([
|
||||
task("parent", "transferring"),
|
||||
{ ...task("child", "transferring"), parentTaskId: "parent" },
|
||||
]), { count: 1, hasAttention: false });
|
||||
});
|
||||
|
||||
test("successful background work is collapsed but failures stay visible", () => {
|
||||
const split = splitBackgroundTransfers([
|
||||
task("a", "completed", true),
|
||||
task("b", "failed", true),
|
||||
task("c", "completed", false),
|
||||
]);
|
||||
assert.deepEqual(split.visible.map((item) => item.id), ["b", "c"]);
|
||||
assert.deepEqual(split.collapsed.map((item) => item.id), ["a"]);
|
||||
});
|
||||
|
||||
test("directory parent detection matches side-queue file-count semantics", () => {
|
||||
assert.equal(isDirectoryParentTask({
|
||||
isDirectory: true,
|
||||
parentTaskId: undefined,
|
||||
progressMode: "files",
|
||||
}), true);
|
||||
assert.equal(isDirectoryParentTask({
|
||||
isDirectory: true,
|
||||
parentTaskId: undefined,
|
||||
progressMode: undefined,
|
||||
}), true);
|
||||
assert.equal(isDirectoryParentTask({
|
||||
isDirectory: true,
|
||||
parentTaskId: "p",
|
||||
progressMode: "files",
|
||||
}), false);
|
||||
assert.equal(isDirectoryParentTask({
|
||||
isDirectory: false,
|
||||
parentTaskId: undefined,
|
||||
progressMode: "bytes",
|
||||
}), false);
|
||||
assert.equal(isDirectoryParentTask({
|
||||
isDirectory: true,
|
||||
parentTaskId: undefined,
|
||||
progressMode: "bytes",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("folder parent progress uses progressive discovery labels not byte formatting", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
if (key === "sftp.transfers.filesDiscoveredProgress") {
|
||||
return `${params?.completed} done · ${params?.discovered} found`;
|
||||
}
|
||||
if (key === "sftp.transfers.filesProgress") {
|
||||
return `${params?.current}/${params?.total} files`;
|
||||
}
|
||||
if (key === "sftp.transfers.filesCount") {
|
||||
return `${params?.count} files`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
const display = buildGlobalTransferProgressDisplay({
|
||||
status: "transferring",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
totalBytes: 12,
|
||||
transferredBytes: 1,
|
||||
speed: 0,
|
||||
phase: "transferring",
|
||||
}, t);
|
||||
assert.equal(display.percent, (1 / 12) * 100);
|
||||
assert.equal(display.detail, "1 done · 12 found");
|
||||
assert.equal(display.indeterminate, false);
|
||||
// Must not look like "1 Bytes / 12 Bytes"
|
||||
assert.doesNotMatch(display.detail, /Bytes/i);
|
||||
});
|
||||
|
||||
test("folder parent scanning stays indeterminate while nothing has completed", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
if (key === "sftp.transfers.filesDiscoveredProgress") {
|
||||
return `${params?.completed} done · ${params?.discovered} found`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
const display = buildGlobalTransferProgressDisplay({
|
||||
status: "transferring",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
totalBytes: 40,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
phase: "scanning",
|
||||
}, t);
|
||||
assert.equal(display.detail, "0 done · 40 found");
|
||||
assert.equal(display.indeterminate, true);
|
||||
});
|
||||
|
||||
test("single-file progress still shows byte totals", () => {
|
||||
const t = (key: string) => key;
|
||||
const display = buildGlobalTransferProgressDisplay({
|
||||
status: "transferring",
|
||||
isDirectory: false,
|
||||
progressMode: "bytes",
|
||||
totalBytes: 1024,
|
||||
transferredBytes: 512,
|
||||
speed: 0,
|
||||
}, t);
|
||||
assert.equal(display.percent, 50);
|
||||
assert.match(display.detail, /512\s*Bytes/);
|
||||
assert.match(display.detail, /1(\.00)?\s*KB/);
|
||||
});
|
||||
|
||||
test("active child summary prefers transferring files over queued", () => {
|
||||
const children = [
|
||||
{ ...task("q1", "queued"), parentTaskId: "p", startTime: 1 },
|
||||
{ ...task("t1", "transferring"), parentTaskId: "p", startTime: 2 },
|
||||
{ ...task("t2", "transferring"), parentTaskId: "p", startTime: 3 },
|
||||
{ ...task("done", "completed"), parentTaskId: "p", startTime: 0 },
|
||||
];
|
||||
assert.deepEqual(
|
||||
pickActiveChildSummaries(children, 2).map((item) => item.id),
|
||||
["t1", "t2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("collapsed active children only show while transferring or soft-draining", () => {
|
||||
assert.equal(shouldShowCollapsedActiveChildren("transferring"), true);
|
||||
assert.equal(shouldShowCollapsedActiveChildren("pausing"), true);
|
||||
assert.equal(shouldShowCollapsedActiveChildren("paused"), false);
|
||||
assert.equal(shouldShowCollapsedActiveChildren("interrupted"), false);
|
||||
assert.equal(shouldShowCollapsedActiveChildren("queued"), false);
|
||||
});
|
||||
|
||||
test("listChildTasksForParent skips cancelled siblings", () => {
|
||||
const tasks = [
|
||||
task("p", "transferring"),
|
||||
{ ...task("c1", "transferring"), parentTaskId: "p" },
|
||||
{ ...task("c2", "cancelled"), parentTaskId: "p" },
|
||||
{ ...task("other", "transferring"), parentTaskId: "x" },
|
||||
];
|
||||
assert.deepEqual(listChildTasksForParent(tasks, "p").map((item) => item.id), ["c1"]);
|
||||
});
|
||||
954
components/GlobalSftpTransferCenter.tsx
Normal file
954
components/GlobalSftpTransferCenter.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
ArrowDownUp,
|
||||
ArrowUpFromLine,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ClipboardCopy,
|
||||
FolderOpen,
|
||||
FolderUp,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
sftpTransferCenterStore,
|
||||
useSftpTransferCenterBadge,
|
||||
useSftpTransferResuming,
|
||||
type SftpTransferCenterSnapshot,
|
||||
} from "../application/state/sftpTransferCenterStore";
|
||||
import { transferRuntime } from "../application/state/sftp/transferRuntime";
|
||||
import { useGlobalSftpTransferActions } from "../application/state/useGlobalSftpTransferActions";
|
||||
export { getGlobalTransferBatchEligibility } from "../domain/sftpTransferActions";
|
||||
import type { FileConflictAction, TransferTask } from "../domain/models";
|
||||
import { canReplaceSftpConflict } from "../domain/sftpConflict";
|
||||
import { estimateTransferEtaSeconds, formatFileSize, formatTransferEta } from "../application/state/sftp/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
/** Stable empty snapshot while the popover is closed — progress ticks must not
|
||||
* re-render TopTabs just to update hidden list rows. */
|
||||
const CLOSED_TRANSFER_CENTER_SNAPSHOT: SftpTransferCenterSnapshot = {
|
||||
tasks: [],
|
||||
activeCount: 0,
|
||||
queuedCount: 0,
|
||||
attentionCount: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Full transfer-center list only while the popover is open. When closed, the
|
||||
* getter always returns the same CLOSED snapshot so progress store notifies do
|
||||
* not re-render this TopTabs child (badge uses a separate stable subscription).
|
||||
*/
|
||||
function useSftpTransferCenterWhenOpen(open: boolean): SftpTransferCenterSnapshot {
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => {
|
||||
// Lifecycle + progress: when the popover is open the list must move with
|
||||
// bytes; when closed getSnapshot returns a stable CLOSED constant so
|
||||
// progress ticks are cheap no-ops for this subscriber.
|
||||
const unsubLifecycle = sftpTransferCenterStore.subscribe(onStoreChange);
|
||||
const unsubProgress = sftpTransferCenterStore.subscribeProgress(onStoreChange);
|
||||
return () => {
|
||||
unsubLifecycle();
|
||||
unsubProgress();
|
||||
};
|
||||
},
|
||||
() => (
|
||||
openRef.current
|
||||
? sftpTransferCenterStore.getSnapshot()
|
||||
: CLOSED_TRANSFER_CENTER_SNAPSHOT
|
||||
),
|
||||
() => CLOSED_TRANSFER_CENTER_SNAPSHOT,
|
||||
);
|
||||
}
|
||||
|
||||
export type GlobalTransferBucket = "all" | "active" | "queued" | "paused" | "attention" | "completed";
|
||||
|
||||
export function getGlobalTransferBucket(task: Pick<TransferTask, "status" | "reconnectRequired">): GlobalTransferBucket {
|
||||
// Reconnect/resume preparation should stay visible with the unfinished work.
|
||||
if (task.status === "pending" && task.reconnectRequired) return "attention";
|
||||
if (task.status === "transferring" || task.status === "pausing") return "active";
|
||||
if (task.status === "pending" || task.status === "queued") return "queued";
|
||||
if (task.status === "paused") return "paused";
|
||||
if (task.status === "interrupted" || task.status === "attention" || task.status === "failed") return "attention";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
export function getGlobalTransferBadge(tasks: readonly TransferTask[]) {
|
||||
const topLevelTasks = tasks.filter((task) => !task.parentTaskId);
|
||||
return {
|
||||
count: topLevelTasks.filter((task) =>
|
||||
["pending", "queued", "transferring", "pausing", "paused", "interrupted"].includes(task.status)
|
||||
).length,
|
||||
// Interrupted after restart and conflict attention both need the user.
|
||||
hasAttention: topLevelTasks.some((task) =>
|
||||
task.status === "attention"
|
||||
|| task.status === "failed"
|
||||
|| task.status === "interrupted"
|
||||
|| task.reconnectRequired === true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function splitBackgroundTransfers(tasks: readonly TransferTask[]) {
|
||||
const collapsed = tasks.filter((task) => task.background && task.status === "completed");
|
||||
const collapsedIds = new Set(collapsed.map((task) => task.id));
|
||||
return {
|
||||
visible: tasks.filter((task) => !collapsedIds.has(task.id)),
|
||||
collapsed,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGlobalTransferStatusOverride(task: Pick<TransferTask, "error" | "pauseUnavailableReason">) {
|
||||
return task.error || task.pauseUnavailableReason;
|
||||
}
|
||||
|
||||
export function getGlobalConflictActionPresentation(
|
||||
action: FileConflictAction,
|
||||
destructiveDirectoryReplace: boolean,
|
||||
) {
|
||||
const safeMerge = destructiveDirectoryReplace && action === "merge";
|
||||
const destructiveReplace = destructiveDirectoryReplace && action === "replace";
|
||||
return {
|
||||
variant: safeMerge || (!destructiveDirectoryReplace && action === "replace") ? "default" : "outline",
|
||||
destructiveReplace,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const BUCKETS: readonly GlobalTransferBucket[] = ["all", "active", "queued", "paused", "attention", "completed"];
|
||||
|
||||
export function getTasksForGlobalTransferBucket(
|
||||
tasks: readonly TransferTask[],
|
||||
bucket: GlobalTransferBucket,
|
||||
): TransferTask[] {
|
||||
return tasks.filter((task) => !task.parentTaskId && (bucket === "all" || getGlobalTransferBucket(task) === bucket));
|
||||
}
|
||||
|
||||
/** Folder parent rows use file-count progress (same model as the SFTP side queue). */
|
||||
export function isDirectoryParentTask(
|
||||
task: Pick<TransferTask, "isDirectory" | "parentTaskId" | "progressMode">,
|
||||
): boolean {
|
||||
if (!task.isDirectory || task.parentTaskId) return false;
|
||||
// Explicit bytes mode would be aggregate size; default folder uploads use files.
|
||||
return task.progressMode !== "bytes";
|
||||
}
|
||||
|
||||
export function listChildTasksForParent(
|
||||
tasks: readonly TransferTask[],
|
||||
parentId: string,
|
||||
): TransferTask[] {
|
||||
return tasks
|
||||
.filter((task) => task.parentTaskId === parentId && task.status !== "cancelled")
|
||||
.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer live children, then queued — for the collapsed "current file" summary.
|
||||
* Callers must not use this for paused/interrupted parents (those hide the row).
|
||||
*/
|
||||
export function pickActiveChildSummaries(
|
||||
children: readonly TransferTask[],
|
||||
limit = 2,
|
||||
): TransferTask[] {
|
||||
const live = children.filter((task) => task.status === "transferring" || task.status === "pausing");
|
||||
if (live.length > 0) return live.slice(0, limit);
|
||||
const waiting = children.filter((task) => task.status === "pending" || task.status === "queued");
|
||||
if (waiting.length > 0) return waiting.slice(0, limit);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Collapsed mini-rows only while the folder is actively moving or soft-draining. */
|
||||
export function shouldShowCollapsedActiveChildren(
|
||||
parentStatus: TransferTask["status"],
|
||||
): boolean {
|
||||
return parentStatus === "transferring" || parentStatus === "pausing";
|
||||
}
|
||||
|
||||
export function getGlobalTransferProgressPercent(
|
||||
task: Pick<TransferTask, "status" | "totalBytes" | "transferredBytes">,
|
||||
): number {
|
||||
if (task.status === "completed") return 100;
|
||||
if (task.totalBytes <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, (task.transferredBytes / task.totalBytes) * 100));
|
||||
}
|
||||
|
||||
export type GlobalTransferProgressDisplay = {
|
||||
percent: number;
|
||||
/** Right-side progress label (file count or bytes). */
|
||||
detail: string;
|
||||
/** True when the bar should pulse (total still unknown). */
|
||||
indeterminate: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build progress labels for a top-level row. Directory parents use file counts
|
||||
* so we never show "1 Bytes / 12 Bytes" for n/m files.
|
||||
*
|
||||
* Folder walks interleave discovery with transfer: totalBytes is "found so far",
|
||||
* not a fixed grand total. Active rows use progressive copy so the UI does not
|
||||
* look like a lying overall percentage.
|
||||
*/
|
||||
export function buildGlobalTransferProgressDisplay(
|
||||
task: Pick<TransferTask, "status" | "isDirectory" | "parentTaskId" | "progressMode" | "totalBytes" | "transferredBytes" | "speed" | "phase">,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): GlobalTransferProgressDisplay {
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
const percent = getGlobalTransferProgressPercent(task);
|
||||
const hasTotal = task.totalBytes > 0;
|
||||
const discovered = Math.max(task.totalBytes, task.transferredBytes);
|
||||
|
||||
if (isDirParent) {
|
||||
const isActive = task.status === "transferring"
|
||||
|| task.status === "pausing"
|
||||
|| task.status === "queued"
|
||||
|| task.status === "pending";
|
||||
// Still discovering, or no completed files yet — pulse rather than freeze a fake %.
|
||||
const indeterminate = isActive && (
|
||||
task.phase === "scanning"
|
||||
|| !hasTotal
|
||||
|| task.transferredBytes <= 0
|
||||
);
|
||||
let detail = "";
|
||||
if (isActive) {
|
||||
if (discovered > 0) {
|
||||
detail = t("sftp.transfers.filesDiscoveredProgress", {
|
||||
completed: task.transferredBytes,
|
||||
discovered,
|
||||
});
|
||||
}
|
||||
} else if (task.status === "completed" && hasTotal) {
|
||||
detail = t("sftp.transfers.filesCount", { count: task.totalBytes });
|
||||
} else if (discovered > 0) {
|
||||
detail = t("sftp.transfers.filesDiscoveredProgress", {
|
||||
completed: task.transferredBytes,
|
||||
discovered,
|
||||
});
|
||||
}
|
||||
// Soft percent of files found so far (discovered may still grow).
|
||||
const softPercent = discovered > 0
|
||||
? Math.min(100, (task.transferredBytes / discovered) * 100)
|
||||
: 0;
|
||||
return {
|
||||
percent: task.status === "completed" ? 100 : softPercent,
|
||||
detail,
|
||||
indeterminate,
|
||||
};
|
||||
}
|
||||
|
||||
const detailParts: string[] = [];
|
||||
if (hasTotal) {
|
||||
detailParts.push(`${formatFileSize(task.transferredBytes)} / ${formatFileSize(task.totalBytes)}`);
|
||||
} else if (task.transferredBytes > 0) {
|
||||
detailParts.push(formatFileSize(task.transferredBytes));
|
||||
}
|
||||
if (task.status === "transferring" && task.speed > 0) {
|
||||
detailParts.push(`${formatFileSize(task.speed)}/s`);
|
||||
}
|
||||
if (task.status === "transferring" && task.speed > 0 && hasTotal) {
|
||||
const eta = formatTransferEta(estimateTransferEtaSeconds(task.totalBytes - task.transferredBytes, task.speed));
|
||||
if (eta) detailParts.push(eta);
|
||||
}
|
||||
return {
|
||||
percent,
|
||||
detail: detailParts.join(" · "),
|
||||
indeterminate: task.status === "transferring" && !hasTotal,
|
||||
};
|
||||
}
|
||||
|
||||
function formatChildByteProgress(task: Pick<TransferTask, "totalBytes" | "transferredBytes" | "status">): string {
|
||||
if (task.totalBytes > 0) {
|
||||
return `${formatFileSize(task.transferredBytes)} / ${formatFileSize(task.totalBytes)}`;
|
||||
}
|
||||
if (task.transferredBytes > 0) return formatFileSize(task.transferredBytes);
|
||||
return "";
|
||||
}
|
||||
|
||||
function statusLabelKey(status: TransferTask["status"]): string {
|
||||
return `sftp.transferCenter.status.${status}`;
|
||||
}
|
||||
|
||||
function TransferAction({ label, onClick, children, destructive = false }: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", destructive && "text-destructive hover:text-destructive")}
|
||||
aria-label={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Truncated label with hover tooltip for the full text (paths, names, errors). */
|
||||
function TruncatedTextWithTooltip({
|
||||
text,
|
||||
tooltip,
|
||||
className,
|
||||
as: Tag = "span",
|
||||
}: {
|
||||
text: string;
|
||||
/** Defaults to `text`. Use when the visible label differs from the full string. */
|
||||
tooltip?: string;
|
||||
className?: string;
|
||||
as?: "span" | "div";
|
||||
}) {
|
||||
if (!text) return null;
|
||||
const tip = tooltip || text;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Tag className={cn("min-w-0 truncate", className)}>{text}</Tag>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-sm break-all">
|
||||
{tip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTransferPathLine(task: Pick<TransferTask, "sourcePath" | "targetPath" | "sourceHostLabel" | "targetHostLabel">): string {
|
||||
const source = `${task.sourceHostLabel ? `${task.sourceHostLabel}: ` : ""}${task.sourcePath}`;
|
||||
const target = `${task.targetHostLabel ? `${task.targetHostLabel}: ` : ""}${task.targetPath}`;
|
||||
return `${source} → ${target}`;
|
||||
}
|
||||
|
||||
function TransferRow({
|
||||
task,
|
||||
childTasks = [],
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
isLast,
|
||||
}: {
|
||||
task: TransferTask;
|
||||
childTasks?: readonly TransferTask[];
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const folderReplaceWarningId = React.useId();
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status);
|
||||
const activeChildren = useMemo(
|
||||
() => (showCollapsedChildren ? pickActiveChildSummaries(childTasks, 2) : []),
|
||||
[childTasks, showCollapsedChildren],
|
||||
);
|
||||
// Soft-drain still completes ranges / children after Pause. Snapshot the
|
||||
// visible parent bar + child mini-rows on first "pausing" paint so the row
|
||||
// does not twitch (file-count bumps, child list swaps, width animation).
|
||||
// When the parent reaches "paused", hide the mini-rows entirely — partial
|
||||
// children flipping paused↔transferring used to blink the report line.
|
||||
const pausingSnapshotRef = useRef<{
|
||||
task: TransferTask;
|
||||
children: TransferTask[];
|
||||
} | null>(null);
|
||||
if (task.status === "pausing") {
|
||||
if (!pausingSnapshotRef.current) {
|
||||
pausingSnapshotRef.current = {
|
||||
task: { ...task },
|
||||
children: activeChildren.map((child) => ({ ...child })),
|
||||
};
|
||||
}
|
||||
} else if (pausingSnapshotRef.current) {
|
||||
pausingSnapshotRef.current = null;
|
||||
}
|
||||
const displayTask = task.status === "pausing" && pausingSnapshotRef.current
|
||||
? { ...pausingSnapshotRef.current.task, status: "pausing" as const, speed: 0, phase: undefined }
|
||||
: task;
|
||||
const displayChildren = !showCollapsedChildren
|
||||
? []
|
||||
: (task.status === "pausing" && pausingSnapshotRef.current
|
||||
? pausingSnapshotRef.current.children
|
||||
: activeChildren);
|
||||
const progress = buildGlobalTransferProgressDisplay(displayTask, t);
|
||||
const canToggleChildren = isDirParent && childTasks.length > 0;
|
||||
const canControl = sftpTransferCenterStore.canControl(task.id);
|
||||
// Keep the play button as a spinner for the whole reconnect window, not only
|
||||
// the brief "pending" status before a dedicated session opens.
|
||||
const storeResuming = task.reconnectRequired === true
|
||||
&& ["pending", "queued", "transferring"].includes(task.status)
|
||||
&& !task.error;
|
||||
const sharedResuming = useSftpTransferResuming(task.id);
|
||||
const isResuming = sharedResuming || storeResuming;
|
||||
const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming;
|
||||
// Orphaned tasks after app restart (interrupted / attention / paused without a
|
||||
// live panel owner) must still expose resume/cancel from the global center.
|
||||
// Conflict rows must use resolveConflict — Resume would overwrite blindly.
|
||||
// Non-resumable attention rows (e.g. duplicate-destination refusals) are
|
||||
// terminal: resuming them would start a second writer on the same path.
|
||||
// Do not show Resume during soft-drain "pausing" — play + spinner together
|
||||
// makes the row twitch as buttons appear/disappear mid-drain.
|
||||
const canResume = !isResuming && !task.conflict && (
|
||||
["paused", "interrupted"].includes(task.status)
|
||||
|| (task.status === "attention" && task.resumable !== false)
|
||||
|| (task.status === "failed" && task.resumable !== false && (task.checkpointBytes ?? 0) > 0)
|
||||
) && canControl;
|
||||
const isPausing = task.status === "pausing";
|
||||
const canCancel = ["pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention"].includes(task.status) && canControl;
|
||||
const canRetry = task.status === "failed" && task.retryable !== false && canControl;
|
||||
const isTerminal = ["completed", "failed", "cancelled"].includes(task.status);
|
||||
// Status line is for lifecycle/error only — not pause capability notes.
|
||||
// "cannot be paused safely" used to render here during healthy progress and
|
||||
// looked like a failure even while bytes were flowing.
|
||||
const statusText = (() => {
|
||||
const override = getGlobalTransferStatusOverride(task);
|
||||
if (override) return override;
|
||||
if (isResuming) return t("sftp.transferCenter.status.resuming");
|
||||
// Lifecycle pause labels beat phase ("transferring") so pausing never reads
|
||||
// as 传输中 while soft-drain finishes the current range.
|
||||
if (task.status === "pausing" || task.status === "paused" || task.status === "interrupted") {
|
||||
return t(statusLabelKey(task.status));
|
||||
}
|
||||
// Scanning runs as pending/transferring; never override terminal statuses
|
||||
// (cancelled/failed/completed) that still carry a stale phase value.
|
||||
if (
|
||||
task.phase === "scanning"
|
||||
&& (task.status === "pending" || task.status === "queued" || task.status === "transferring")
|
||||
) {
|
||||
return t("sftp.transferCenter.phase.scanning");
|
||||
}
|
||||
if (task.phase && task.status === "transferring") {
|
||||
return t(`sftp.transferCenter.phase.${task.phase}`);
|
||||
}
|
||||
return t(statusLabelKey(task.status));
|
||||
})();
|
||||
const directionIcon = isDirParent
|
||||
? <FolderUp size={15} />
|
||||
: task.direction === "download"
|
||||
? <ArrowDownToLine size={15} />
|
||||
: <ArrowUpFromLine size={15} />;
|
||||
|
||||
const openTarget = (forResume = false) => {
|
||||
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-target", {
|
||||
detail: { task, forResume },
|
||||
}));
|
||||
};
|
||||
const resumeTask = () => {
|
||||
// Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP.
|
||||
// Only force-open the panel when the row still needs a live owner/adoption
|
||||
// (e.g. conflict) — not on every remote-to-remote resume click.
|
||||
if (task.conflict || (task.status === "attention" && !task.reconnectRequired && task.direction === "remote-to-remote")) {
|
||||
openTarget(true);
|
||||
}
|
||||
// Single process-level resume (soft live walk / hard reconnect internal).
|
||||
void transferRuntime.resume(task.id);
|
||||
};
|
||||
|
||||
const barWidth = progress.indeterminate
|
||||
? "100%"
|
||||
: `${displayTask.status === "completed" ? 100 : progress.percent}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("border-border/40 px-3 py-2.5 hover:bg-muted/30", isLast ? "border-b-0" : "border-b")}
|
||||
data-section="global-sftp-transfer-row"
|
||||
data-transfer-status={task.status}
|
||||
data-directory-parent={isDirParent ? "true" : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
|
||||
{directionIcon}
|
||||
</div>
|
||||
<button type="button" className="min-w-0 flex-1 overflow-hidden text-left" onClick={() => openTarget()}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<TruncatedTextWithTooltip text={task.fileName} className="text-xs font-medium" />
|
||||
{task.background && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] text-muted-foreground">
|
||||
{t("sftp.transferCenter.background")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<TruncatedTextWithTooltip
|
||||
as="div"
|
||||
text={formatTransferPathLine(task)}
|
||||
className="mt-0.5 text-[10px] text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{canToggleChildren && (
|
||||
<TransferAction
|
||||
label={expanded ? t("sftp.transfers.collapseChildren") : t("sftp.transfers.expandChildren")}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
{expanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
</TransferAction>
|
||||
)}
|
||||
{isResuming && (
|
||||
<div className="flex h-8 w-8 items-center justify-center" role="status" aria-label={t("sftp.transferCenter.status.resuming")}>
|
||||
<Loader2 size={13} className="animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{canPause && (
|
||||
<TransferAction label={t("sftp.transferCenter.pause")} onClick={() => { void transferRuntime.pause(task.id); }}>
|
||||
<Pause size={13} />
|
||||
</TransferAction>
|
||||
)}
|
||||
{task.status === "pausing" && (
|
||||
<div className="flex h-8 w-8 items-center justify-center" role="status" aria-label={t("sftp.transferCenter.status.pausing")}>
|
||||
<Loader2 size={13} className="animate-spin text-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
{canResume && (
|
||||
<TransferAction label={t("sftp.transferCenter.resume")} onClick={resumeTask}>
|
||||
<Play size={13} />
|
||||
</TransferAction>
|
||||
)}
|
||||
{task.status === "queued" && canControl && (
|
||||
<TransferAction label={t("sftp.transferCenter.prioritize")} onClick={() => { void sftpTransferCenterStore.prioritize(task.id); }}>
|
||||
<ArrowUpFromLine size={13} />
|
||||
</TransferAction>
|
||||
)}
|
||||
{canRetry && (
|
||||
<TransferAction label={t("sftp.transfers.retryAction")} onClick={() => { void sftpTransferCenterStore.retry(task.id); }}>
|
||||
<RefreshCw size={13} />
|
||||
</TransferAction>
|
||||
)}
|
||||
{isTerminal && (
|
||||
<TransferAction label={t("sftp.transfers.dismissAction")} onClick={() => sftpTransferCenterStore.dismiss(task.id)}>
|
||||
<Trash2 size={13} />
|
||||
</TransferAction>
|
||||
)}
|
||||
{canCancel && (
|
||||
<TransferAction destructive label={t("common.cancel")} onClick={() => { void transferRuntime.cancel(task.id); }}>
|
||||
<X size={13} />
|
||||
</TransferAction>
|
||||
)}
|
||||
<TransferAction label={t("sftp.transfers.copyTargetPath")} onClick={() => { void navigator.clipboard.writeText(task.targetPath); }}>
|
||||
<ClipboardCopy size={13} />
|
||||
</TransferAction>
|
||||
<TransferAction label={t("sftp.transfers.openTargetFolder")} onClick={() => openTarget()}>
|
||||
<FolderOpen size={13} />
|
||||
</TransferAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div
|
||||
className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-secondary"
|
||||
role="progressbar"
|
||||
aria-label={task.fileName}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress.indeterminate ? undefined : Math.round(progress.percent)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
// Animate only while actively transferring — soft-drain width
|
||||
// freezes still looked like a twitch with transition.
|
||||
!isPausing && task.status !== "paused" && task.status !== "interrupted"
|
||||
&& "transition-[width] duration-200 ease-linear",
|
||||
progress.indeterminate && "animate-pulse bg-primary/60",
|
||||
!progress.indeterminate && (
|
||||
task.status === "failed"
|
||||
? "bg-destructive"
|
||||
: task.status === "paused" || task.status === "interrupted" || isPausing
|
||||
? "bg-amber-500"
|
||||
: "bg-primary"
|
||||
),
|
||||
)}
|
||||
style={{ width: barWidth }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
||||
{progress.indeterminate ? "…" : displayTask.totalBytes > 0 || displayTask.status === "completed" ? `${progress.percent.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center justify-between gap-3 text-[10px] text-muted-foreground">
|
||||
<TruncatedTextWithTooltip
|
||||
text={statusText}
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
(task.status === "failed" || task.status === "attention") && "text-destructive",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 shrink truncate text-right font-mono" title={progress.detail || undefined}>
|
||||
{progress.detail}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Collapsed: show currently transferring child file(s) without expanding the whole tree. */}
|
||||
{isDirParent && !expanded && displayChildren.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 space-y-1 border-l-2 pl-2",
|
||||
isPausing || task.status === "paused" ? "border-amber-500/40" : "border-primary/30",
|
||||
)}
|
||||
data-section="global-sftp-transfer-active-children"
|
||||
>
|
||||
{displayChildren.map((child) => {
|
||||
const childPercent = getGlobalTransferProgressPercent(child);
|
||||
const childDetail = formatChildByteProgress(child);
|
||||
const childFrozen = isPausing
|
||||
|| task.status === "paused"
|
||||
|| child.status === "pausing"
|
||||
|| child.status === "paused";
|
||||
return (
|
||||
<div key={child.id} className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
|
||||
<TruncatedTextWithTooltip text={child.fileName} className="flex-1 text-muted-foreground" />
|
||||
<span className="shrink-0 font-mono">
|
||||
{childDetail || (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
|
||||
</span>
|
||||
</div>
|
||||
{child.totalBytes > 0 && (
|
||||
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
child.status === "completed"
|
||||
? "bg-emerald-500/80"
|
||||
: childFrozen
|
||||
? "bg-amber-500/80"
|
||||
: "bg-primary/80",
|
||||
)}
|
||||
style={{ width: `${child.status === "completed" ? 100 : childPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDirParent && expanded && childTasks.length > 0 && (
|
||||
<div
|
||||
className="mt-2 max-h-48 space-y-1 overflow-y-auto border-t border-border/40 pt-2"
|
||||
data-section="global-sftp-transfer-child-list"
|
||||
>
|
||||
{childTasks.map((child) => {
|
||||
const childPercent = getGlobalTransferProgressPercent(child);
|
||||
const childDetail = formatChildByteProgress(child);
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center gap-2 rounded px-1 py-1 text-[10px] hover:bg-muted/40"
|
||||
data-transfer-status={child.status}
|
||||
>
|
||||
<TruncatedTextWithTooltip
|
||||
text={child.fileName}
|
||||
tooltip={child.targetPath || child.fileName}
|
||||
className="flex-1 text-muted-foreground"
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-muted-foreground">
|
||||
{childDetail
|
||||
|| (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
|
||||
</span>
|
||||
{child.status === "transferring" && (
|
||||
<Loader2 size={10} className="shrink-0 animate-spin text-primary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.status === "attention" && task.conflict && canControl && (() => {
|
||||
const conflict = task.conflict!;
|
||||
const canMerge = conflict.isDirectory && conflict.existingType === "directory";
|
||||
const destructiveDirectoryReplace = canMerge;
|
||||
const unresolvedFolderType = conflict.isDirectory && !conflict.existingType;
|
||||
const canReplace = canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
|
||||
const actions = [
|
||||
"stop",
|
||||
"skip",
|
||||
"duplicate",
|
||||
...(canMerge ? (["merge"] as const) : []),
|
||||
...(canReplace ? (["replace"] as const) : []),
|
||||
] as const;
|
||||
const applyAllActions = [
|
||||
"skip",
|
||||
"duplicate",
|
||||
...(canMerge ? (["merge"] as const) : []),
|
||||
...(canReplace ? (["replace"] as const) : []),
|
||||
] as const;
|
||||
const renderConflictAction = (action: FileConflictAction, applyToAll = false) => {
|
||||
const presentation = getGlobalConflictActionPresentation(action, destructiveDirectoryReplace);
|
||||
return (
|
||||
<Button
|
||||
key={applyToAll ? `all-${action}` : action}
|
||||
variant={presentation.variant}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 px-2 text-[10px]",
|
||||
presentation.destructiveReplace
|
||||
&& "border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||
)}
|
||||
aria-describedby={presentation.destructiveReplace ? folderReplaceWarningId : undefined}
|
||||
onClick={() => { void sftpTransferCenterStore.resolveConflict(task.id, action, applyToAll); }}
|
||||
>
|
||||
{t(`sftp.conflict.action.${action}`)}
|
||||
{applyToAll && <> · {t("sftp.transferCenter.applyAll")}</>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{destructiveDirectoryReplace && (
|
||||
<div
|
||||
id={folderReplaceWarningId}
|
||||
className="flex items-start gap-1.5 rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[10px] leading-4"
|
||||
>
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-destructive" />
|
||||
<p>
|
||||
{t("sftp.conflict.folderMergeHint")}{" "}
|
||||
<span className="font-medium text-destructive">
|
||||
{t("sftp.conflict.folderReplaceWarning")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{unresolvedFolderType && (
|
||||
<div className="flex items-start gap-1.5 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-4">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-amber-600" />
|
||||
<p>{t("sftp.conflict.folderUnknownDesc")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => renderConflictAction(action))}
|
||||
{(conflict.applyToAllCount ?? 0) > 1
|
||||
&& applyAllActions.map((action) => renderConflictAction(action, true))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferList({ tasks, childrenByParent, empty }: {
|
||||
tasks: readonly TransferTask[];
|
||||
childrenByParent: ReadonlyMap<string, TransferTask[]>;
|
||||
empty: React.ReactNode;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Keep folder expansion when a row scrolls out of the mounted viewport.
|
||||
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const virtual = tasks.length > 20;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: tasks.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 112,
|
||||
getItemKey: (index) => tasks[index].id,
|
||||
overscan: 4,
|
||||
enabled: virtual,
|
||||
});
|
||||
const renderRow = (task: TransferTask, index: number) => (
|
||||
<TransferRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
childTasks={childrenByParent.get(task.id) ?? []}
|
||||
expanded={expandedIds.has(task.id)}
|
||||
isLast={index === tasks.length - 1}
|
||||
onToggleExpanded={() => setExpandedIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(task.id)) next.delete(task.id);
|
||||
else next.add(task.id);
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div ref={scrollRef} className="max-h-[460px] overflow-auto" data-section="global-sftp-transfer-list">
|
||||
{tasks.length === 0 ? empty : !virtual ? tasks.map(renderRow) : (
|
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${row.start}px)` }}
|
||||
>
|
||||
{renderRow(tasks[row.index], row.index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobalSftpTransferCenter() {
|
||||
const { t } = useI18n();
|
||||
// Badge is a stable store snapshot (identity unchanged on pure progress).
|
||||
// Full task list is only needed while the popover is open — when closed,
|
||||
// progress ticks used to re-render this TopTabs child on every byte.
|
||||
const badge = useSftpTransferCenterBadge();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Folder drops fire this so the scanning row is visible immediately without
|
||||
// requiring the user to notice the badge first.
|
||||
useEffect(() => {
|
||||
const openCenter = () => setOpen(true);
|
||||
window.addEventListener("netcatty:open-sftp-transfer-center", openCenter);
|
||||
return () => window.removeEventListener("netcatty:open-sftp-transfer-center", openCenter);
|
||||
}, []);
|
||||
const snapshot = useSftpTransferCenterWhenOpen(open);
|
||||
const [bucket, setBucket] = useState<GlobalTransferBucket>("all");
|
||||
const [showBackground, setShowBackground] = useState(false);
|
||||
const childrenByParent = useMemo(() => {
|
||||
const map = new Map<string, TransferTask[]>();
|
||||
for (const task of snapshot.tasks) {
|
||||
if (!task.parentTaskId || task.status === "cancelled") continue;
|
||||
const list = map.get(task.parentTaskId) ?? [];
|
||||
list.push(task);
|
||||
map.set(task.parentTaskId, list);
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => a.startTime - b.startTime);
|
||||
}
|
||||
return map;
|
||||
}, [snapshot.tasks]);
|
||||
const counts = useMemo(() => Object.fromEntries(BUCKETS.map((item) => [
|
||||
item,
|
||||
getTasksForGlobalTransferBucket(snapshot.tasks, item).length,
|
||||
])) as Record<GlobalTransferBucket, number>, [snapshot.tasks]);
|
||||
const bucketTasks = useMemo(() => getTasksForGlobalTransferBucket(snapshot.tasks, bucket)
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.startTime - a.startTime), [bucket, snapshot.tasks]);
|
||||
const { visible, collapsed } = splitBackgroundTransfers(bucketTasks);
|
||||
const displayed = showBackground ? [...visible, ...collapsed] : visible;
|
||||
const { batchEligibility, pauseAll, resumeAll } = useGlobalSftpTransferActions(snapshot.tasks);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
|
||||
style={{ color: "var(--top-tabs-muted, hsl(var(--muted-foreground)))" }}
|
||||
aria-label={t("sftp.transferCenter.title")}
|
||||
data-section="global-sftp-transfer-toggle"
|
||||
>
|
||||
<ArrowDownUp size={15} />
|
||||
{badge.count > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex min-h-3 min-w-3 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] leading-3 text-primary-foreground">
|
||||
{badge.count > 99 ? "99+" : badge.count}
|
||||
</span>
|
||||
)}
|
||||
{badge.hasAttention && (
|
||||
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.transferCenter.title")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={5}
|
||||
className="w-[min(460px,calc(100vw-24px))] overflow-hidden p-0 app-no-drag"
|
||||
data-section="global-sftp-transfer-center"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
|
||||
<div className="min-w-0 pr-2 text-sm font-semibold">
|
||||
{t("sftp.transferCenter.title")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={batchEligibility.pausableCount === 0}>
|
||||
<Pause size={12} className="mr-1" />{t("sftp.transferCenter.pauseAll")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={batchEligibility.resumableCount === 0}>
|
||||
<Play size={12} className="mr-1" />{t("sftp.transferCenter.resumeAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-0 px-2 pt-1" role="tablist" aria-label={t("sftp.transferCenter.title")}>
|
||||
{BUCKETS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={cn(
|
||||
// Selected: keep a clear accent underline (not just text color).
|
||||
// Unselected: gray label only — no shared full-width hairline.
|
||||
"relative min-w-0 flex-1 truncate px-1.5 py-2 text-[11px] transition-colors",
|
||||
bucket === item
|
||||
? "font-medium text-primary after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary"
|
||||
: "text-muted-foreground hover:text-foreground/80",
|
||||
)}
|
||||
onClick={() => setBucket(item)}
|
||||
role="tab"
|
||||
aria-selected={bucket === item}
|
||||
>
|
||||
{t(`sftp.transferCenter.bucket.${item}`)}
|
||||
{counts[item] > 0 && <span className="ml-1 text-[10px] opacity-80">{counts[item]}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<TransferList
|
||||
key={`${bucket}-${showBackground}`}
|
||||
tasks={displayed}
|
||||
childrenByParent={childrenByParent}
|
||||
empty={(
|
||||
<div className="flex h-40 flex-col items-center justify-center text-muted-foreground">
|
||||
{badge.hasAttention && bucket !== "attention" && bucket !== "all" ? <AlertCircle size={22} /> : <ArrowDownUp size={22} />}
|
||||
<span className="mt-2 text-xs">{t("sftp.transferCenter.empty")}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(() => {
|
||||
const showBackgroundToggle = collapsed.length > 0;
|
||||
const showClear = bucket === "completed" && counts.completed > 0;
|
||||
if (!showBackgroundToggle && !showClear) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div>
|
||||
{showBackgroundToggle && (
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={() => setShowBackground((value) => !value)}>
|
||||
{showBackground
|
||||
? t("sftp.transferCenter.hideBackground")
|
||||
: t("sftp.transferCenter.showBackground", { count: collapsed.length })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showClear && (
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={() => {
|
||||
sftpTransferCenterStore.clearTerminal("completed");
|
||||
sftpTransferCenterStore.clearTerminal("cancelled");
|
||||
}}>
|
||||
<Trash2 size={11} className="mr-1" />{t("sftp.transferCenter.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
69
components/GlobalSftpTransferCenter.virtualization.test.tsx
Normal file
69
components/GlobalSftpTransferCenter.virtualization.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("large transfer histories mount only visible rows and can scroll to the last file", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
NodeFilter: env.window.NodeFilter,
|
||||
HTMLInputElement: env.window.HTMLInputElement,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
Object.defineProperties(env.window.HTMLElement.prototype, {
|
||||
offsetWidth: { configurable: true, get: () => 460 },
|
||||
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
|
||||
});
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
store.publishOwner("virtual-list-test", Array.from({ length: 1_000 }, (_, index) => ({
|
||||
id: `virtual-file-${index}`, fileName: `file-${index}.bin`,
|
||||
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
|
||||
sourceConnectionId: "source", targetConnectionId: "local", direction: "download" as const,
|
||||
status: "queued" as const, totalBytes: 1_000, transferredBytes: 0, speed: 0,
|
||||
startTime: index + 1, isDirectory: false,
|
||||
})));
|
||||
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
|
||||
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.ok(toggle);
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
const rows = () => env.document.querySelectorAll("[role=progressbar]");
|
||||
assert.ok(rows().length > 0 && rows().length < 40, `rendered ${rows().length} rows`);
|
||||
assert.ok(env.document.querySelector('[role=progressbar][aria-label="file-999.bin"]'));
|
||||
const firstRow = env.document.querySelector('[data-transfer-status="queued"]');
|
||||
assert.ok(firstRow);
|
||||
assert.equal(firstRow.matches('.last\\:border-b-0:last-child'), false, "a virtual row must not lose its divider just because it has a wrapper");
|
||||
|
||||
const scroll = env.document.querySelector<HTMLElement>("[data-section=global-sftp-transfer-list]");
|
||||
assert.ok(scroll);
|
||||
scroll.scrollTop = 112 * 1_000 - 460;
|
||||
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
|
||||
await flushEffects();
|
||||
assert.ok(rows().length > 0 && rows().length < 40);
|
||||
assert.ok(env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]'));
|
||||
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
|
||||
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
});
|
||||
112
components/GroupDetailsPanel.test.tsx
Normal file
112
components/GroupDetailsPanel.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
hasGroupSshFields,
|
||||
hasGroupTelnetFields,
|
||||
includeMissingIdentityOption,
|
||||
resolveGroupFormIdentityId,
|
||||
selectGroupSshIdentity,
|
||||
selectGroupTelnetIdentity,
|
||||
} from "./GroupDetailsPanel.tsx";
|
||||
|
||||
test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", () => {
|
||||
assert.equal(hasGroupSshFields({ identityId: "" }), true);
|
||||
assert.equal(hasGroupSshFields({}), false);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
|
||||
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetIdentityId: "identity-1" }), true);
|
||||
assert.equal(hasGroupTelnetFields({}), false);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel replaces manual SSH credentials with a reusable identity", () => {
|
||||
const result = selectGroupSshIdentity(
|
||||
{
|
||||
username: "manual",
|
||||
password: "secret",
|
||||
identityFileId: "key-1",
|
||||
identityFilePaths: ["~/.ssh/id_ed25519"],
|
||||
},
|
||||
{
|
||||
id: "identity-1",
|
||||
label: "Shared admin",
|
||||
username: "admin",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.identityId, "identity-1");
|
||||
assert.equal(result.username, "admin");
|
||||
assert.equal(result.password, undefined);
|
||||
assert.equal(result.identityFileId, undefined);
|
||||
assert.equal(result.identityFilePaths, undefined);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel replaces manual Telnet credentials with a reusable identity", () => {
|
||||
const result = selectGroupTelnetIdentity(
|
||||
{ telnetUsername: "manual", telnetPassword: "secret" },
|
||||
"identity-1",
|
||||
);
|
||||
|
||||
assert.equal(result.telnetIdentityId, "identity-1");
|
||||
assert.equal(result.telnetUsername, undefined);
|
||||
assert.equal(result.telnetPassword, undefined);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel explicitly clears inherited identities for manual credentials", () => {
|
||||
const ssh = selectGroupSshIdentity({}, undefined, "", "parent-ssh-identity");
|
||||
const telnet = selectGroupTelnetIdentity({}, "", "parent-telnet-identity");
|
||||
|
||||
assert.equal(ssh.identityId, "");
|
||||
assert.equal(telnet.telnetIdentityId, "");
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel clears key authentication before switching to a manual password", () => {
|
||||
const selected = selectGroupSshIdentity(
|
||||
{},
|
||||
{
|
||||
id: "key-identity",
|
||||
label: "Key identity",
|
||||
username: "admin",
|
||||
authMethod: "key",
|
||||
keyId: "key-1",
|
||||
created: 1,
|
||||
},
|
||||
);
|
||||
const cleared = selectGroupSshIdentity(selected, undefined);
|
||||
const manual = { ...cleared, username: "operator", password: "secret" };
|
||||
|
||||
assert.equal(manual.identityId, undefined);
|
||||
assert.equal(manual.authMethod, undefined);
|
||||
assert.equal(manual.username, "operator");
|
||||
assert.equal(manual.password, "secret");
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel keeps a deleted identity visible so it can be cleared", () => {
|
||||
const options = includeMissingIdentityOption([], "deleted-identity", "Identity not found");
|
||||
|
||||
assert.deepEqual(options, [{ value: "deleted-identity", label: "Identity not found" }]);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel shows saved child manual credentials instead of a parent identity", () => {
|
||||
assert.equal(
|
||||
resolveGroupFormIdentityId(
|
||||
{ username: "child-user", password: "child-password" },
|
||||
"parent-ssh-identity",
|
||||
"ssh",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
resolveGroupFormIdentityId(
|
||||
{ telnetUsername: "child-user", telnetPassword: "child-password" },
|
||||
"parent-telnet-identity",
|
||||
"telnet",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
1031
components/GroupDetailsPanel.tsx
Normal file
1031
components/GroupDetailsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
604
components/GroupSshSettingsSection.tsx
Normal file
604
components/GroupSshSettingsSection.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Eye, EyeOff, FileKey, FolderOpen, Globe, Key, Link2, MoreHorizontal, Plus, Shield, TerminalSquare, Trash2, Variable, X } from "lucide-react";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type GroupSshSettingsSectionProps = Record<string, any>;
|
||||
|
||||
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; onToggle: () => void }> = ({ label, hint, enabled, onToggle }) => {
|
||||
return (
|
||||
<HostDetailsSettingRow label={label} hint={hint}>
|
||||
<Switch checked={enabled} onCheckedChange={() => onToggle()} />
|
||||
</HostDetailsSettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = ({
|
||||
sshEnabled,
|
||||
t,
|
||||
removeSsh,
|
||||
form,
|
||||
update,
|
||||
showPassword,
|
||||
setShowPassword,
|
||||
availableKeys,
|
||||
identities,
|
||||
identityOptions,
|
||||
updateSshIdentity,
|
||||
effectiveSshIdentityId,
|
||||
setSelectedCredentialType,
|
||||
selectedCredentialType,
|
||||
credentialPopoverOpen,
|
||||
setCredentialPopoverOpen,
|
||||
keysByCategory,
|
||||
newKeyFilePath,
|
||||
setNewKeyFilePath,
|
||||
inheritedLegacyAlgorithms,
|
||||
inheritedSkipEcdsaHostKey,
|
||||
inheritedStartupCommandRunMode,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
inheritedAlgorithmOverrides,
|
||||
proxySummaryLabel,
|
||||
setActiveSubPanel,
|
||||
chainedHosts,
|
||||
}) => {
|
||||
if (!sshEnabled) return null;
|
||||
const startupCommandRunModeFallback = inheritedStartupCommandRunMode ?? "paste";
|
||||
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? startupCommandRunModeFallback;
|
||||
|
||||
return (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("vault.groups.details.ssh")}
|
||||
className="overflow-hidden"
|
||||
action={
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="end" className="min-w-[160px]">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-secondary rounded-md transition-colors"
|
||||
onClick={removeSsh}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("vault.groups.details.removeProtocol")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">SSH on</span>
|
||||
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="22"
|
||||
value={form.port ?? ""}
|
||||
onChange={(e) =>
|
||||
update("port", e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
className="h-8 flex-1 min-w-0 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.port")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(identities.length > 0 || effectiveSshIdentityId) && (
|
||||
<Combobox
|
||||
options={identityOptions}
|
||||
value={effectiveSshIdentityId || ""}
|
||||
onValueChange={updateSshIdentity}
|
||||
placeholder={t("hostDetails.identity.suggestions")}
|
||||
emptyText={t("common.noResultsFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!effectiveSshIdentityId && (<>
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.username || ""}
|
||||
onChange={(e) => update("username", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value || undefined)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
{!effectiveSshIdentityId && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
{form.authMethod === "certificate" ? (
|
||||
<Shield size={14} className="text-primary" />
|
||||
) : (
|
||||
<Key size={14} className="text-primary" />
|
||||
)}
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{availableKeys.find((k) => k.id === form.identityFileId)?.label || "Key"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!effectiveSshIdentityId && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 h-8 px-2 rounded-md bg-secondary/50 border border-border/60" style={{ maxWidth: '100%' }}>
|
||||
<FileKey size={12} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-mono truncate" style={{ maxWidth: '320px' }}>{keyPath}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = (form.identityFilePaths || []).filter((_, i) => i !== idx);
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
if (paths.length === 0) update("authMethod", undefined);
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credential type selection with inline popover - hidden when credential is selected */}
|
||||
{!effectiveSshIdentityId && !form.identityFileId &&
|
||||
!selectedCredentialType && (
|
||||
<Popover
|
||||
open={credentialPopoverOpen}
|
||||
onOpenChange={setCredentialPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>{t("hostDetails.credential.keyCertificate")}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[200px] p-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("key");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Key size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.key")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("certificate");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Shield size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Key selection combobox - appears after selecting "Key" type */}
|
||||
{selectedCredentialType === "key" &&
|
||||
!effectiveSshIdentityId && !form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
|
||||
{selectedCredentialType === "certificate" &&
|
||||
!effectiveSshIdentityId && !form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.certificate.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
icon: (
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
),
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
emptyText={t("hostDetails.certs.empty")}
|
||||
icon={
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!effectiveSshIdentityId && !form.identityFileId && selectedCredentialType === "localKeyFile" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
setNewKeyFilePath("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
hint={t("hostDetails.agentForwarding.desc")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
|
||||
{/* Startup Command — Textarea so multi-line sequences are typeable
|
||||
here just like on the per-host details panel (#1083 follow-up). */}
|
||||
<Textarea
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value || undefined)}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.startupCommand.runMode")}
|
||||
hint={t("hostDetails.startupCommand.runMode.help")}
|
||||
>
|
||||
<Select
|
||||
value={effectiveStartupCommandRunMode}
|
||||
onValueChange={(value) => update(
|
||||
"startupCommandRunMode",
|
||||
value === startupCommandRunModeFallback ? undefined : value,
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lineDelay">{t("hostDetails.startupCommand.runMode.lineDelay")}</SelectItem>
|
||||
<SelectItem value="paste">{t("hostDetails.startupCommand.runMode.paste")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
|
||||
{/* Display the *effective* value (this group's field falling
|
||||
back to the resolved parent default). Same rationale as
|
||||
in HostDetailsPanel — without the fallback, a child group
|
||||
that inherits a flag from a parent would show "off" in
|
||||
the UI while connections still applied it. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
hint={t("hostDetails.legacyAlgorithms.desc")}
|
||||
enabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
hint={t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? inheritedSkipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? inheritedLegacyAlgorithms)}
|
||||
inheritedFromGroup={inheritedAlgorithmOverrides}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Proxy */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3 py-2 transition-colors hover:bg-secondary/70"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.proxy")}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{(form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 cursor-default">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-[160px] truncate text-xs"
|
||||
>
|
||||
{proxySummaryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{proxySummaryLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Host Chaining */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3 py-2 transition-colors hover:bg-secondary/70"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.jumpHosts")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{chainedHosts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex min-h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3 py-2 transition-colors hover:bg-secondary/70"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Variable size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.envVars")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(form.environmentVariables?.length || 0) > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{form.environmentVariables!.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Mosh */}
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => update("moshEnabled", !form.moshEnabled)}
|
||||
/>
|
||||
{form.moshEnabled && (
|
||||
<Input
|
||||
placeholder={t("hostDetails.moshServerPath") || "mosh-server path"}
|
||||
value={form.moshServerPath || ""}
|
||||
onChange={(e) => update("moshServerPath", e.target.value || undefined)}
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* EternalTerminal */}
|
||||
<ToggleRow
|
||||
label="EternalTerminal"
|
||||
enabled={!!form.etEnabled}
|
||||
onToggle={() => update("etEnabled", !form.etEnabled)}
|
||||
/>
|
||||
{form.etEnabled && (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hostDetails.et.port") || "ET server port (2022)"}
|
||||
value={form.etPort ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
update("etPort", v === "" ? undefined : Number(v));
|
||||
}}
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Backspace behavior — terminal input mapping, lives at the
|
||||
bottom of the SSH section so it doesn't get visually
|
||||
grouped with the algorithm controls above. */}
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
);
|
||||
};
|
||||
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';
|
||||
36
components/HostDetailsAdvancedSections.test.ts
Normal file
36
components/HostDetailsAdvancedSections.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./HostDetailsAdvancedSections.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('advanced host settings expose per-host SSH connection timeouts', () => {
|
||||
assert.match(source, /hostDetails\.section\.sshTimeouts/);
|
||||
assert.match(source, /value=\{form\.sshTcpConnectTimeoutSeconds \?\?/);
|
||||
assert.match(source, /update\("sshTcpConnectTimeoutSeconds", value\)/);
|
||||
assert.match(source, /value=\{form\.sshAuthReadyTimeoutSeconds \?\?/);
|
||||
assert.match(source, /update\("sshAuthReadyTimeoutSeconds", value\)/);
|
||||
assert.equal(source.match(/!Number\.isFinite\(value\)/g)?.length, 2);
|
||||
});
|
||||
|
||||
test('editing enabled SSH agent controls persists the enabled state', () => {
|
||||
assert.match(source, /effectiveGroupDefaults,\s*effectiveAuthMethod,/);
|
||||
assert.doesNotMatch(source, /resolveHostAuthMethodSelection/);
|
||||
assert.match(source, /const systemSshAgentSupported = effectiveAuthMethod === "auto" \|\| effectiveAuthMethod === "key"/);
|
||||
assert.match(source, /effectiveAuthMethod === "key" && form\.useSshAgent === true/);
|
||||
assert.match(source, /enabled=\{systemSshAgentEnabled\}/);
|
||||
assert.match(source, /disabled=\{!systemSshAgentSupported\}/);
|
||||
assert.match(source, /resolveSshAgentToggleUpdate\(previous, effectiveAuthMethod, enabling\)/);
|
||||
assert.match(source, /\{systemSshAgentEnabled && \(/);
|
||||
assert.match(source, /useSshAgent: true,\s*identityAgent:/);
|
||||
assert.match(source, /useSshAgent: true,\s*identitiesOnly:/);
|
||||
});
|
||||
|
||||
test('enabling SSH agent login clears an imported none sentinel', () => {
|
||||
assert.match(source, /resolveSshAgentToggleUpdate/);
|
||||
});
|
||||
|
||||
test('login and forwarding warnings use independent agent status', () => {
|
||||
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
|
||||
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
|
||||
});
|
||||
810
components/HostDetailsAdvancedSections.tsx
Normal file
810
components/HostDetailsAdvancedSections.tsx
Normal file
@@ -0,0 +1,810 @@
|
||||
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
|
||||
import React from "react";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { clearHostFontSizeOverride, clearHostThemeOverride } from "../domain/terminalAppearance";
|
||||
import { resolveSshAgentToggleUpdate } from "../domain/sshAuth";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { HostDetailsSection, HostDetailsSettingRow, HostDetailsOverrideReset } from "./host-details";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
import {
|
||||
DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS,
|
||||
DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS,
|
||||
MAX_SSH_CONNECTION_TIMEOUT_SECONDS,
|
||||
} from "../domain/sshConnectionTimeouts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type HostDetailsAdvancedSectionsProps = Record<string, any>;
|
||||
|
||||
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; disabled?: boolean; onToggle: () => void }> = ({ label, hint, enabled, disabled, onToggle }) => {
|
||||
return (
|
||||
<HostDetailsSettingRow label={label} hint={hint}>
|
||||
<Switch checked={enabled} disabled={disabled} onCheckedChange={() => onToggle()} />
|
||||
</HostDetailsSettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
update,
|
||||
effectiveThemeId,
|
||||
hasEffectiveThemeOverride,
|
||||
effectiveFontSize,
|
||||
hasEffectiveFontSizeOverride,
|
||||
sshAgentStatus,
|
||||
sshForwardingAgentStatus,
|
||||
effectiveGroupDefaults,
|
||||
effectiveAuthMethod,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
chainedHosts,
|
||||
setActiveSubPanel,
|
||||
clearHostChain,
|
||||
proxySummaryType,
|
||||
proxySummaryLabel,
|
||||
proxySummaryTooltip,
|
||||
clearProxyConfig,
|
||||
}) => {
|
||||
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
const inheritedStartupCommandRunMode = effectiveGroupDefaults?.startupCommandRunMode ?? "paste";
|
||||
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? inheritedStartupCommandRunMode;
|
||||
const systemSshAgentSupported = effectiveAuthMethod === "auto" || effectiveAuthMethod === "key";
|
||||
const systemSshAgentEnabled = effectiveAuthMethod === "auto"
|
||||
? form.useSshAgent !== false
|
||||
: effectiveAuthMethod === "key" && form.useSshAgent === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<Palette size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.appearance")}
|
||||
>
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<div className="flex w-full items-center gap-1 rounded-lg bg-secondary/50 p-2 transition-colors hover:bg-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() => setActiveSubPanel("theme-select")}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden shrink-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasEffectiveThemeOverride && (
|
||||
<HostDetailsOverrideReset
|
||||
label={t("common.useGlobal")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setForm((prev) => clearHostThemeOverride(prev));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<HostDetailsSettingRow label="Font Size">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize > MIN_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize - 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize <= MIN_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
value={effectiveFontSize}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="h-8 w-16 text-center"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">pt</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveFontSize < MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize + 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={effectiveFontSize >= MAX_FONT_SIZE}
|
||||
className="h-8 w-8 px-0"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{hasEffectiveFontSizeOverride && (
|
||||
<HostDetailsOverrideReset
|
||||
size="sm"
|
||||
label={t("common.useGlobal")}
|
||||
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Wifi size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.mosh")}
|
||||
>
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
moshEnabled: true,
|
||||
etEnabled: false,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("moshEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Wifi size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.et")}
|
||||
>
|
||||
<ToggleRow
|
||||
label="EternalTerminal"
|
||||
enabled={!!form.etEnabled}
|
||||
onToggle={() => {
|
||||
const enabling = !form.etEnabled;
|
||||
if (enabling) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
etEnabled: true,
|
||||
moshEnabled: false,
|
||||
x11Forwarding: undefined,
|
||||
}));
|
||||
} else {
|
||||
update("etEnabled", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.etEnabled && (
|
||||
<>
|
||||
<HostDetailsSettingRow label={t("hostDetails.et.port")} hint={t("hostDetails.et.port.desc")}>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8 w-28"
|
||||
placeholder="2022"
|
||||
value={form.etPort ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
update("etPort", v === "" ? undefined : Number(v));
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
</>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* System SSH Agent login */}
|
||||
<HostDetailsSection
|
||||
icon={<KeyRound size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.systemSshAgent")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.systemSshAgent")}
|
||||
hint={t("hostDetails.systemSshAgent.desc")}
|
||||
enabled={systemSshAgentEnabled}
|
||||
disabled={!systemSshAgentSupported}
|
||||
onToggle={() => setForm((previous: typeof form) => {
|
||||
const enabled = effectiveAuthMethod === "auto"
|
||||
? previous.useSshAgent !== false
|
||||
: effectiveAuthMethod === "key" && previous.useSshAgent === true;
|
||||
const enabling = !enabled;
|
||||
return {
|
||||
...previous,
|
||||
// Automatic mode treats the ambient agent as an optional first
|
||||
// choice. Keep the unset state when re-enabling that default so
|
||||
// an unavailable agent still falls back to local keys/password.
|
||||
...resolveSshAgentToggleUpdate(previous, effectiveAuthMethod, enabling),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{systemSshAgentEnabled && (
|
||||
<>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.systemSshAgent.socket")}
|
||||
hint={t("hostDetails.systemSshAgent.socket.desc")}
|
||||
>
|
||||
<Input
|
||||
className="h-8 w-44 font-mono text-xs"
|
||||
placeholder="$SSH_AUTH_SOCK"
|
||||
value={form.identityAgent ?? ""}
|
||||
onChange={(event) => setForm((previous: typeof form) => ({
|
||||
...previous,
|
||||
useSshAgent: true,
|
||||
identityAgent: event.target.value || undefined,
|
||||
}))}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.systemSshAgent.identitiesOnly")}
|
||||
hint={t("hostDetails.systemSshAgent.identitiesOnly.desc")}
|
||||
enabled={!!form.identitiesOnly}
|
||||
onToggle={() => setForm((previous: typeof form) => ({
|
||||
...previous,
|
||||
useSshAgent: true,
|
||||
identitiesOnly: !previous.identitiesOnly,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.systemSshAgent.notRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.systemSshAgent.notRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<HostDetailsSection
|
||||
icon={<Forward size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.agentForwarding")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
hint={t("hostDetails.agentForwarding.desc")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
{form.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.agentForwarding.agentNotRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.agentNotRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
|
||||
<HostDetailsSection
|
||||
icon={<Router size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.deviceType")}
|
||||
>
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
hint={t("hostDetails.deviceType.desc")}
|
||||
enabled={effectiveDeviceType === 'network'}
|
||||
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
|
||||
/>
|
||||
{effectiveDeviceType === 'network' && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.deviceType.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
aria-label={t("hostDetails.os.title")}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
|
||||
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
|
||||
</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
|
||||
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOST_OS_SELECTIONS.map((value) => (
|
||||
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
{/* SSH Algorithms */}
|
||||
<HostDetailsSection
|
||||
icon={<ShieldAlert size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.sshAlgorithms")}
|
||||
>
|
||||
{/* Display the *effective* value of these toggles (host field
|
||||
falling back to the resolved group default). Without the
|
||||
fallback a host that inherits the flag from its group would
|
||||
show "off" while the runtime applied it anyway, and the
|
||||
toggle's onToggle handler would compute the wrong "next"
|
||||
value from the raw host field. */}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
hint={t("hostDetails.legacyAlgorithms.desc")}
|
||||
enabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
onToggle={() => update(
|
||||
"legacyAlgorithms",
|
||||
!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms),
|
||||
)}
|
||||
/>
|
||||
{(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms) && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.legacyAlgorithms.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.skipEcdsaHostKey")}
|
||||
hint={t("hostDetails.skipEcdsaHostKey.desc")}
|
||||
enabled={!!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey)}
|
||||
onToggle={() => update(
|
||||
"skipEcdsaHostKey",
|
||||
!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey),
|
||||
)}
|
||||
/>
|
||||
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("hostDetails.algorithms.advanced")}
|
||||
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
({t("hostDetails.algorithms.customized")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showAlgorithmOverrides
|
||||
? <ChevronUp size={14} className="text-muted-foreground" />
|
||||
: <ChevronDown size={14} className="text-muted-foreground" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<AlgorithmOverridesPanel
|
||||
value={form.algorithms}
|
||||
/* Use the effective legacy flag (host value falling back to
|
||||
the currently selected group's default) so the seed
|
||||
reflects what the host would actually advertise. We
|
||||
read from `effectiveGroupDefaults` (re-resolved on
|
||||
every form.group change), not the `groupDefaults` prop
|
||||
— otherwise switching the host into a different group
|
||||
without saving first would seed from the original
|
||||
group's flag and silently mis-populate the override. */
|
||||
legacyEnabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
|
||||
inheritedFromGroup={effectiveGroupDefaults?.algorithms}
|
||||
onChange={(next) => update("algorithms", next)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Terminal Behavior — input/output key mappings (backspace, etc.) */}
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.terminalBehavior")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.lineTimestamps")}
|
||||
hint={t("hostDetails.lineTimestamps.desc")}
|
||||
enabled={!!form.showLineTimestamps}
|
||||
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
|
||||
/>
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Per-host keepalive override */}
|
||||
<HostDetailsSection
|
||||
icon={<HeartPulse size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.keepalive")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.keepalive.override")}
|
||||
hint={t("hostDetails.keepalive.desc")}
|
||||
enabled={!!form.keepaliveOverride}
|
||||
onToggle={() => {
|
||||
const next = !form.keepaliveOverride;
|
||||
update("keepaliveOverride", next);
|
||||
// Seed sensible per-host defaults the first time the user
|
||||
// turns the override on so the inputs aren't empty.
|
||||
if (next) {
|
||||
if (form.keepaliveInterval == null) update("keepaliveInterval", 0);
|
||||
if (form.keepaliveCountMax == null) update("keepaliveCountMax", 3);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.keepaliveOverride && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<HostDetailsSettingRow label={t("hostDetails.keepalive.interval")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
className="h-8 w-24 text-xs"
|
||||
value={form.keepaliveInterval ?? 0}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v)) return;
|
||||
if (v < 0 || v > 3600) return;
|
||||
update("keepaliveInterval", v);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<HostDetailsSettingRow label={t("hostDetails.keepalive.countMax")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="h-8 w-24 text-xs"
|
||||
value={form.keepaliveCountMax ?? 3}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v)) return;
|
||||
if (v < 1 || v > 100) return;
|
||||
update("keepaliveCountMax", v);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
{(form.keepaliveInterval ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground break-words pl-1">
|
||||
{t("hostDetails.keepalive.disabledHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Timer size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.sshTimeouts")}
|
||||
>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sshTimeouts.tcpConnect")}
|
||||
hint={t("hostDetails.sshTimeouts.tcpConnect.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
|
||||
className="h-8 w-20 text-xs"
|
||||
aria-label={t("hostDetails.sshTimeouts.tcpConnect")}
|
||||
value={form.sshTcpConnectTimeoutSeconds ?? DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS}
|
||||
onChange={(e) => {
|
||||
const value = Number.parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
|
||||
update("sshTcpConnectTimeoutSeconds", value);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sshTimeouts.authReady")}
|
||||
hint={t("hostDetails.sshTimeouts.authReady.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
|
||||
className="h-8 w-20 text-xs"
|
||||
aria-label={t("hostDetails.sshTimeouts.authReady")}
|
||||
value={form.sshAuthReadyTimeoutSeconds ?? DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS}
|
||||
onChange={(e) => {
|
||||
const value = Number.parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
|
||||
update("sshAuthReadyTimeoutSeconds", value);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<HostDetailsSection
|
||||
icon={<Link2 size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.jumpHosts")}
|
||||
action={
|
||||
chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearHostChain();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1 pl-5">
|
||||
{chainedHosts.slice(0, 5).map((h, idx) => (
|
||||
<div key={h.id} className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">{idx + 1}.</span>
|
||||
<span className="truncate">
|
||||
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{chainedHosts.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{chainedHosts.length - 5} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Proxy Configuration */}
|
||||
<HostDetailsSection
|
||||
icon={<Globe size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{proxySummaryType}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{proxySummaryLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{proxySummaryTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.proxy.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<HostDetailsSection
|
||||
icon={<Variable size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.envVars")}
|
||||
>
|
||||
{(form.environmentVariables?.length || 0) > 0 ? (
|
||||
<button
|
||||
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<span className="text-sm truncate">
|
||||
{form.environmentVariables
|
||||
?.slice(0, 2)
|
||||
.map((v) => `${v.name}=${v.value}`)
|
||||
.join(", ")}
|
||||
{(form.environmentVariables?.length || 0) > 2 && "..."}
|
||||
</span>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setForm((prev) => ({ ...prev, environmentVariables: undefined }));
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("env-vars")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.envVars.add")}
|
||||
</Button>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* Startup Command */}
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.startupCommand")}
|
||||
hint={t("hostDetails.startupCommand.help")}
|
||||
>
|
||||
<Textarea
|
||||
placeholder={effectiveGroupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value)}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.startupCommand.runMode")}
|
||||
hint={t("hostDetails.startupCommand.runMode.help")}
|
||||
>
|
||||
<Select
|
||||
value={effectiveStartupCommandRunMode}
|
||||
onValueChange={(value) => update(
|
||||
"startupCommandRunMode",
|
||||
value === inheritedStartupCommandRunMode ? undefined : value,
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lineDelay">{t("hostDetails.startupCommand.runMode.lineDelay")}</SelectItem>
|
||||
<SelectItem value="paste">{t("hostDetails.startupCommand.runMode.paste")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
333
components/HostDetailsConnectionSections.test.tsx
Normal file
333
components/HostDetailsConnectionSections.test.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import test from "node:test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { Host, SSHKey } from "../types.ts";
|
||||
import {
|
||||
applyEffectiveHostAuthMethodSelection,
|
||||
detachEffectiveHostIdentity,
|
||||
HOST_AUTH_METHOD_CHOICES,
|
||||
HostDetailsConnectionSections,
|
||||
removeSelectedHostCredential,
|
||||
shouldForceAuthMethodReselect,
|
||||
} from "./HostDetailsConnectionSections.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const longCredentialLabel =
|
||||
"D:\\download\\acdrOgxses_wuzh02.hpccube.com_really_really_long_private_key_name.pem";
|
||||
|
||||
const availableKey: SSHKey = {
|
||||
id: "key-1",
|
||||
label: longCredentialLabel,
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderConnectionSections = (
|
||||
formOverrides: Record<string, unknown> = {},
|
||||
groupDefaults?: Record<string, unknown>,
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsConnectionSections, {
|
||||
t: (key: string) => key,
|
||||
form: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
os: "linux",
|
||||
authMethod: "key",
|
||||
identityFileId: availableKey.id,
|
||||
...formOverrides,
|
||||
},
|
||||
setForm: () => {},
|
||||
update: () => {},
|
||||
groupDefaults,
|
||||
effectiveAuthMethod: formOverrides.effectiveAuthMethod || formOverrides.authMethod || "key",
|
||||
effectiveIdentityId: formOverrides.effectiveIdentityId || formOverrides.identityId,
|
||||
selectedIdentity: undefined,
|
||||
clearIdentity: () => {},
|
||||
identities: [],
|
||||
identitySuggestionsOpen: false,
|
||||
filteredIdentitySuggestions: [],
|
||||
setIdentitySuggestionsOpen: () => {},
|
||||
availableKeys: [availableKey],
|
||||
applyIdentity: () => {},
|
||||
showPassword: false,
|
||||
setShowPassword: () => {},
|
||||
pendingReferenceKeyPath: null,
|
||||
setPendingReferenceKeyPath: () => {},
|
||||
selectedCredentialType: null,
|
||||
setSelectedCredentialType: () => {},
|
||||
credentialPopoverOpen: false,
|
||||
setCredentialPopoverOpen: () => {},
|
||||
keysByCategory: { key: [availableKey], certificate: [] },
|
||||
newKeyFilePath: "",
|
||||
setNewKeyFilePath: () => {},
|
||||
addLocalKeyFilePath: () => {},
|
||||
handleDistroModeChange: () => {},
|
||||
distroOptions: [],
|
||||
effectiveFormDistro: undefined,
|
||||
getDistroOptionLabel: () => "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("selected host credential keeps the remove button visible with a long name", () => {
|
||||
const markup = renderConnectionSections();
|
||||
|
||||
assert.match(markup, new RegExp(longCredentialLabel.replaceAll("\\", "\\\\")));
|
||||
assert.match(
|
||||
markup,
|
||||
/class="flex items-center gap-2 min-w-0 overflow-hidden p-2 rounded-md bg-secondary\/50 border border-border\/60"/,
|
||||
);
|
||||
assert.match(markup, /class="text-sm min-w-0 flex-1 truncate"/);
|
||||
assert.match(markup, /class="[^"]*h-6 w-6 shrink-0[^"]*"/);
|
||||
});
|
||||
|
||||
test("color and icon settings render for non-Linux hosts", () => {
|
||||
const markup = renderConnectionSections({
|
||||
os: "macos",
|
||||
distro: "macos",
|
||||
iconMode: "custom",
|
||||
iconId: "terminal",
|
||||
});
|
||||
|
||||
assert.match(markup, /hostDetails\.icon\.sectionTitle/);
|
||||
assert.match(markup, /hostDetails\.icon\.colorLabel/);
|
||||
assert.match(markup, /hostDetails\.icon\.manualLabel/);
|
||||
});
|
||||
|
||||
test("basic connection settings do not require operating system selection", () => {
|
||||
const markup = renderConnectionSections({ os: "windows" });
|
||||
assert.doesNotMatch(markup, /hostDetails\.os\./);
|
||||
});
|
||||
|
||||
test("host credentials expose automatic and password-only choices", () => {
|
||||
const markup = renderConnectionSections({
|
||||
authMethod: "auto",
|
||||
identityFileId: undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
HOST_AUTH_METHOD_CHOICES.map(([value, labelKey]) => [value, labelKey]),
|
||||
[
|
||||
["auto", "hostDetails.auth.auto"],
|
||||
["password", "hostDetails.auth.passwordOnly"],
|
||||
["key", "hostDetails.auth.key"],
|
||||
["certificate", "hostDetails.auth.certificate"],
|
||||
],
|
||||
);
|
||||
assert.match(markup, /hostDetails\.auth\.method/);
|
||||
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.auth\.method"/);
|
||||
assert.match(markup, /hostDetails\.auth\.auto/);
|
||||
assert.match(markup, /hostDetails\.auth\.mfaFirst/);
|
||||
// Login method uses the same option-style setting row as other host form controls.
|
||||
assert.match(
|
||||
markup,
|
||||
/hostDetails\.auth\.method[\s\S]*?role="combobox"[\s\S]*?hostDetails\.auth\.auto[\s\S]*?<\/div>\s*<div class="flex h-12 items-center justify-between gap-3 rounded-lg border border-border\/60 bg-secondary\/40 px-3">[\s\S]*hostDetails\.auth\.mfaFirst/,
|
||||
);
|
||||
assert.match(markup, /class="[^"]*h-8 w-32[^"]*"/);
|
||||
assert.match(
|
||||
markup,
|
||||
/role="combobox"[\s\S]*?<span class="min-w-0 flex-1 truncate text-left">hostDetails\.auth\.auto<\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("wires same-value key/certificate reselect through pointer and keyboard handlers", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostDetailsConnectionSections.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /onPointerUp=\{\(\) => \{[\s\S]*shouldForceAuthMethodReselect/);
|
||||
assert.match(source, /onKeyDown=\{\(event\) => \{[\s\S]*shouldForceAuthMethodReselect/);
|
||||
assert.doesNotMatch(source, /onSelect=\{\(\) => \{[\s\S]*shouldForceAuthMethodReselect/);
|
||||
});
|
||||
|
||||
test("username identity suggestions use PopoverAnchor so focus click does not flash-close", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostDetailsConnectionSections.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// Identity suggestions must anchor to the username field without making the
|
||||
// Input a PopoverTrigger (focus open + trigger toggle close = flash).
|
||||
assert.match(
|
||||
source,
|
||||
/identitySuggestionsOpen[\s\S]*?<PopoverAnchor asChild>[\s\S]*?hostDetails\.username\.placeholder[\s\S]*?<\/PopoverAnchor>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/identitySuggestionsOpen[\s\S]*?<PopoverTrigger asChild>[\s\S]*?hostDetails\.username\.placeholder/,
|
||||
);
|
||||
// Anchor clicks are outside interactions in Radix 1.1.x; ignore them so
|
||||
// caret reposition / chevron toggle do not dismiss-then-reopen.
|
||||
assert.match(
|
||||
source,
|
||||
/data-identity-suggestions-anchor[\s\S]*?onInteractOutside=\{\(e\) => \{[\s\S]*?closest\("\[data-identity-suggestions-anchor\]"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("reselecting the current key or certificate method still forces the chooser path", () => {
|
||||
assert.equal(shouldForceAuthMethodReselect("key", "key"), true);
|
||||
assert.equal(shouldForceAuthMethodReselect("certificate", "certificate"), true);
|
||||
assert.equal(shouldForceAuthMethodReselect("password", "password"), false);
|
||||
assert.equal(shouldForceAuthMethodReselect("auto", "auto"), false);
|
||||
assert.equal(shouldForceAuthMethodReselect("key", "password"), false);
|
||||
});
|
||||
|
||||
test("ET hosts do not offer the unsupported interactive-auth preference", () => {
|
||||
const legacyEtMarkup = renderConnectionSections({ etEnabled: true });
|
||||
const explicitEtMarkup = renderConnectionSections({ protocol: "et" });
|
||||
const inheritedEtMarkup = renderConnectionSections({}, { etEnabled: true });
|
||||
const hostOverrideMarkup = renderConnectionSections(
|
||||
{ etEnabled: false },
|
||||
{ etEnabled: true },
|
||||
);
|
||||
|
||||
assert.doesNotMatch(legacyEtMarkup, /hostDetails\.auth\.mfaFirst/);
|
||||
assert.doesNotMatch(explicitEtMarkup, /hostDetails\.auth\.mfaFirst/);
|
||||
assert.doesNotMatch(inheritedEtMarkup, /hostDetails\.auth\.mfaFirst/);
|
||||
assert.match(hostOverrideMarkup, /hostDetails\.auth\.mfaFirst/);
|
||||
});
|
||||
|
||||
test("host authentication choices remain visible for a selected identity", () => {
|
||||
const markup = renderConnectionSections({
|
||||
identityId: "identity-1",
|
||||
authMethod: "password",
|
||||
});
|
||||
|
||||
assert.match(markup, /hostDetails\.auth\.method/);
|
||||
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.auth\.method"/);
|
||||
assert.match(
|
||||
markup,
|
||||
/role="combobox"[\s\S]*?<span class="min-w-0 flex-1 truncate text-left">hostDetails\.auth\.passwordOnly<\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("host authentication choices show the inherited effective method", () => {
|
||||
const markup = renderConnectionSections({
|
||||
authMethod: undefined,
|
||||
identityFileId: undefined,
|
||||
effectiveAuthMethod: "password",
|
||||
});
|
||||
|
||||
assert.match(markup, /hostDetails\.auth\.method/);
|
||||
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.auth\.method"/);
|
||||
assert.match(
|
||||
markup,
|
||||
/role="combobox"[\s\S]*?<span class="min-w-0 flex-1 truncate text-left">hostDetails\.auth\.passwordOnly<\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("reselecting an inherited authentication method preserves group credentials", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
} as Host;
|
||||
|
||||
assert.equal(applyEffectiveHostAuthMethodSelection(host, "key", "key"), host);
|
||||
});
|
||||
|
||||
test("selecting automatic opts out of an inherited identity", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
authMethod: "auto",
|
||||
} as Host;
|
||||
|
||||
assert.deepEqual(
|
||||
applyEffectiveHostAuthMethodSelection(host, "auto", "password"),
|
||||
{
|
||||
...host,
|
||||
authPolicyVersion: 1,
|
||||
identityId: "",
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: undefined,
|
||||
useSshAgent: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("overriding inherited authentication preserves the effective username", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "",
|
||||
} as Host;
|
||||
|
||||
assert.equal(
|
||||
applyEffectiveHostAuthMethodSelection(host, "password", "key", "deploy").username,
|
||||
"deploy",
|
||||
);
|
||||
});
|
||||
|
||||
test("detaching an inherited identity preserves the effective username", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "",
|
||||
} as Host;
|
||||
|
||||
assert.deepEqual(detachEffectiveHostIdentity(host, "deploy"), {
|
||||
...host,
|
||||
identityId: "",
|
||||
username: "deploy",
|
||||
});
|
||||
});
|
||||
|
||||
test("removing a selected key resets stale agent settings for automatic auth", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
authMethod: "key",
|
||||
authPolicyVersion: 1,
|
||||
identityFileId: "key-1",
|
||||
useSshAgent: false,
|
||||
} as Host;
|
||||
|
||||
assert.deepEqual(removeSelectedHostCredential(host, "key"), {
|
||||
...host,
|
||||
authMethod: "auto",
|
||||
identityId: "",
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: undefined,
|
||||
useSshAgent: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("an inherited deleted identity remains visible and clearable", () => {
|
||||
const markup = renderConnectionSections({
|
||||
authMethod: undefined,
|
||||
identityFileId: undefined,
|
||||
effectiveAuthMethod: "auto",
|
||||
effectiveIdentityId: "deleted-group-identity",
|
||||
});
|
||||
|
||||
assert.match(markup, /hostDetails\.identity\.missing/);
|
||||
assert.doesNotMatch(markup, /placeholder="hostDetails\.username\.placeholder"/);
|
||||
});
|
||||
839
components/HostDetailsConnectionSections.tsx
Normal file
839
components/HostDetailsConnectionSections.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { applyHostAuthMethodSelection } from "../domain/sshAuth";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type HostDetailsConnectionSectionsProps = Record<string, any>;
|
||||
|
||||
export const HOST_AUTH_METHOD_CHOICES = [
|
||||
["auto", "hostDetails.auth.auto"],
|
||||
["password", "hostDetails.auth.passwordOnly"],
|
||||
["key", "hostDetails.auth.key"],
|
||||
["certificate", "hostDetails.auth.certificate"],
|
||||
] as const;
|
||||
|
||||
export function shouldForceAuthMethodReselect(
|
||||
nextMethod: "auto" | "password" | "key" | "certificate",
|
||||
currentMethod: "auto" | "password" | "key" | "certificate",
|
||||
): boolean {
|
||||
// Radix Select skips onValueChange when the value is unchanged. Key/certificate
|
||||
// still need that path so re-opening the chooser keeps working.
|
||||
return (
|
||||
nextMethod === currentMethod
|
||||
&& (nextMethod === "key" || nextMethod === "certificate")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const applyEffectiveHostAuthMethodSelection = (
|
||||
host: Host,
|
||||
authMethod: "auto" | "password" | "key" | "certificate",
|
||||
effectiveAuthMethod: "auto" | "password" | "key" | "certificate",
|
||||
effectiveUsername?: string,
|
||||
): Host => {
|
||||
if (authMethod === effectiveAuthMethod) return host;
|
||||
const selected = applyHostAuthMethodSelection(host, authMethod, effectiveAuthMethod);
|
||||
return !selected.username?.trim() && effectiveUsername?.trim()
|
||||
? { ...selected, username: effectiveUsername }
|
||||
: selected;
|
||||
};
|
||||
|
||||
export const detachEffectiveHostIdentity = (host: Host, effectiveUsername?: string): Host => ({
|
||||
...host,
|
||||
identityId: "",
|
||||
...(!host.username?.trim() && effectiveUsername?.trim()
|
||||
? { username: effectiveUsername }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export const removeSelectedHostCredential = (
|
||||
host: Host,
|
||||
effectiveAuthMethod: "auto" | "password" | "key" | "certificate",
|
||||
effectiveUsername?: string,
|
||||
): Host => {
|
||||
const authMethod = host.password ? "password" : "auto";
|
||||
const selected = applyHostAuthMethodSelection(host, authMethod, effectiveAuthMethod);
|
||||
return !selected.username?.trim() && effectiveUsername?.trim()
|
||||
? { ...selected, username: effectiveUsername }
|
||||
: selected;
|
||||
};
|
||||
|
||||
export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectionsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
update,
|
||||
groupDefaults,
|
||||
effectiveAuthMethod,
|
||||
effectiveUsername,
|
||||
effectiveIdentityId,
|
||||
selectedIdentity,
|
||||
clearIdentity,
|
||||
identities,
|
||||
identitySuggestionsOpen,
|
||||
filteredIdentitySuggestions,
|
||||
setIdentitySuggestionsOpen,
|
||||
availableKeys,
|
||||
applyIdentity,
|
||||
showPassword,
|
||||
setShowPassword,
|
||||
pendingReferenceKeyPath,
|
||||
setPendingReferenceKeyPath,
|
||||
selectedCredentialType,
|
||||
setSelectedCredentialType,
|
||||
credentialPopoverOpen,
|
||||
setCredentialPopoverOpen,
|
||||
keysByCategory,
|
||||
newKeyFilePath,
|
||||
setNewKeyFilePath,
|
||||
addLocalKeyFilePath,
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
}) => {
|
||||
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
|
||||
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
|
||||
previous,
|
||||
authMethod,
|
||||
effectiveAuthMethod,
|
||||
effectiveUsername,
|
||||
));
|
||||
if (authMethod === "auto" || authMethod === "password") {
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
setCredentialPopoverOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedCredentialType(authMethod);
|
||||
setCredentialPopoverOpen(false);
|
||||
};
|
||||
|
||||
const resolvedAuthMethod = HOST_AUTH_METHOD_CHOICES.some(([value]) => value === effectiveAuthMethod)
|
||||
? effectiveAuthMethod
|
||||
: "auto";
|
||||
const selectedAuthMethodLabel = t(
|
||||
HOST_AUTH_METHOD_CHOICES.find(([value]) => value === resolvedAuthMethod)?.[1] ?? "hostDetails.auth.auto",
|
||||
);
|
||||
const effectiveEtEnabled = form.etEnabled ?? groupDefaults?.etEnabled;
|
||||
const effectiveProtocol = form.protocol ?? groupDefaults?.protocol;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<MapPin size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.address")}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("hostDetails.hostname.placeholder")}
|
||||
value={form.hostname}
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 w-full"
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<KeyRound size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.portCredentials")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">SSH on</span>
|
||||
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port ?? ""}
|
||||
onChange={(e) => update("port", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder={groupDefaults?.port ? String(groupDefaults.port) : "22"}
|
||||
className="h-8 flex-1 min-w-0 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.port")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.auth.method")}
|
||||
hint={t(`hostDetails.auth.${resolvedAuthMethod}.desc`)}
|
||||
>
|
||||
<Select
|
||||
value={resolvedAuthMethod}
|
||||
onValueChange={(val) => selectAuthMethod(val as "auto" | "password" | "key" | "certificate")}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-32 gap-2"
|
||||
aria-label={t("hostDetails.auth.method")}
|
||||
>
|
||||
{/*
|
||||
Radix Select only mirrors the selected item text after the
|
||||
closed control is measured. Render the current label directly
|
||||
so the default value is never blank on first paint.
|
||||
*/}
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{selectedAuthMethodLabel}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOST_AUTH_METHOD_CHOICES.map(([value, labelKey]) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
textValue={t(labelKey)}
|
||||
onPointerUp={() => {
|
||||
// Radix skips consumer onValueChange for an unchanged value.
|
||||
// Key/certificate still need the same-item path to reopen the chooser.
|
||||
if (shouldForceAuthMethodReselect(value, resolvedAuthMethod)) {
|
||||
selectAuthMethod(value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
(event.key === "Enter" || event.key === " ")
|
||||
&& shouldForceAuthMethodReselect(value, resolvedAuthMethod)
|
||||
) {
|
||||
selectAuthMethod(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
{!effectiveEtEnabled && effectiveProtocol !== "et" && (
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.auth.mfaFirst")}
|
||||
hint={t("hostDetails.auth.mfaFirst.desc")}
|
||||
>
|
||||
<Switch
|
||||
checked={!!form.requiresMfa}
|
||||
onCheckedChange={(val) => update("requiresMfa" as keyof Host, val)}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
)}
|
||||
{selectedIdentity ? (
|
||||
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
|
||||
<User size={16} className="text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{selectedIdentity.label}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.clear")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : effectiveIdentityId ? (
|
||||
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
|
||||
<User size={16} className="text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{t("hostDetails.identity.missing")}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.clear")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const hasIdentities = identities.length > 0;
|
||||
if (!hasIdentities) {
|
||||
return (
|
||||
<Input
|
||||
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => update("username", e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={
|
||||
identitySuggestionsOpen &&
|
||||
filteredIdentitySuggestions.length > 0
|
||||
}
|
||||
onOpenChange={setIdentitySuggestionsOpen}
|
||||
>
|
||||
{/*
|
||||
Anchor (not Trigger): wrapping the username Input in
|
||||
PopoverTrigger races focus→open with the same click's
|
||||
toggle→close, which flashes saved identities then closes.
|
||||
Anchor is not exempt from Radix outside dismissal (only
|
||||
Trigger is), so tag it and ignore those events below.
|
||||
*/}
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className="relative"
|
||||
data-identity-suggestions-anchor=""
|
||||
>
|
||||
<Input
|
||||
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
update("username", next);
|
||||
const q = next.toLowerCase().trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
setIdentitySuggestionsOpen(matches.length > 0);
|
||||
}}
|
||||
onFocus={() => {
|
||||
const q = (form.username || "").toLowerCase().trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
setIdentitySuggestionsOpen(matches.length > 0);
|
||||
}}
|
||||
className="h-10 pr-9"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setIdentitySuggestionsOpen((prev) => {
|
||||
if (prev) return false;
|
||||
const q = (form.username || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
return matches.length > 0;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="p-0 border-border/60"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest("[data-identity-suggestions-anchor]")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{filteredIdentitySuggestions.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t("common.noResultsFound")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredIdentitySuggestions.map((identity) => {
|
||||
const keyLabel = identity.keyId
|
||||
? availableKeys.find(
|
||||
(k) => k.id === identity.keyId,
|
||||
)?.label
|
||||
: undefined;
|
||||
const methodLabel =
|
||||
identity.authMethod === "certificate"
|
||||
? t("hostDetails.credential.certificate")
|
||||
: identity.authMethod === "key"
|
||||
? t("hostDetails.credential.key")
|
||||
: t("keychain.identity.method.passwordOnly");
|
||||
const summaryParts = [
|
||||
identity.username,
|
||||
identity.password ? "******" : undefined,
|
||||
keyLabel,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={identity.id}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applyIdentity(identity);
|
||||
}}
|
||||
>
|
||||
<div className="h-8 w-8 rounded-md bg-green-500/15 text-green-500 flex items-center justify-center shrink-0">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{identity.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{methodLabel}
|
||||
{summaryParts.length
|
||||
? ` - ${summaryParts.join(", ")}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{!selectedIdentity && !form.identityId && (
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Password toggle - shown when password is entered */}
|
||||
{!selectedIdentity && !form.identityId && form.password && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.password.save")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={form.savePassword ?? true}
|
||||
onCheckedChange={(val) => update("savePassword" as keyof Host, val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
|
||||
{keyPath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{keyPath}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
if (keyPath === pendingReferenceKeyPath) {
|
||||
setPendingReferenceKeyPath(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
{!selectedIdentity && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 min-w-0 overflow-hidden p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
{form.authMethod === "certificate" ? (
|
||||
<Shield size={14} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Key size={14} className="text-primary shrink-0" />
|
||||
)}
|
||||
<span className="text-sm min-w-0 flex-1 truncate">
|
||||
{availableKeys.find((k) => k.id === form.identityFileId)
|
||||
?.label || "Key"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
setForm((previous: Host) => removeSelectedHostCredential(
|
||||
previous,
|
||||
effectiveAuthMethod,
|
||||
effectiveUsername,
|
||||
));
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credential type selection with inline popover - hidden when credential is selected */}
|
||||
{!selectedIdentity &&
|
||||
!form.identityFileId &&
|
||||
!selectedCredentialType && (
|
||||
<Popover
|
||||
open={credentialPopoverOpen}
|
||||
onOpenChange={setCredentialPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>{t("hostDetails.credential.keyCertificate")}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[200px] p-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("key");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Key size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.key")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("certificate");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Shield size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Key selection combobox - appears after selecting "Key" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "key" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
update("identityFilePaths", undefined);
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.certificate.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
icon: (
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
),
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
update("identityFilePaths", undefined);
|
||||
setPendingReferenceKeyPath(null);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
emptyText={t("hostDetails.certs.empty")}
|
||||
icon={
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
addLocalKeyFilePath(newKeyFilePath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
addLocalKeyFilePath(filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType(null);
|
||||
setNewKeyFilePath("");
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<FolderLock size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.sftp")}
|
||||
>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sftp.fileProtocol")}
|
||||
hint={t("hostDetails.sftp.fileProtocol.desc")}
|
||||
>
|
||||
<Select
|
||||
value={form.sftpFileProtocol || "auto"}
|
||||
onValueChange={(val) => {
|
||||
const protocol = val as Host["sftpFileProtocol"];
|
||||
update("sftpFileProtocol", protocol);
|
||||
// SCP mode has no sudo-sftp elevation path; clear a contradictory saved flag.
|
||||
if (protocol === "scp" && form.sftpSudo) {
|
||||
update("sftpSudo", false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32">
|
||||
<SelectValue placeholder={t("hostDetails.sftp.fileProtocol.auto")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.sftp.fileProtocol.auto")}</SelectItem>
|
||||
<SelectItem value="sftp">{t("hostDetails.sftp.fileProtocol.sftp")}</SelectItem>
|
||||
<SelectItem value="scp">{t("hostDetails.sftp.fileProtocol.scp")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sftp.sudo")}
|
||||
hint={t("hostDetails.sftp.sudo.desc")}
|
||||
>
|
||||
<Switch
|
||||
checked={form.sftpSudo || false}
|
||||
onCheckedChange={(val) => update("sftpSudo", val)}
|
||||
disabled={form.sftpFileProtocol === "scp"}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.sftp.encoding")}
|
||||
hint={t("hostDetails.sftp.encoding.desc")}
|
||||
>
|
||||
<Select
|
||||
value={form.sftpEncoding || "auto"}
|
||||
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<Shapes size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.icon.sectionTitle")}
|
||||
hint={t("hostDetails.icon.desc")}
|
||||
>
|
||||
<HostIconPicker
|
||||
previewHost={form as Host}
|
||||
distroMode={form.distroMode}
|
||||
manualDistro={form.manualDistro}
|
||||
effectiveDistro={effectiveFormDistro}
|
||||
distroOptions={distroOptions}
|
||||
getDistroOptionLabel={getDistroOptionLabel}
|
||||
iconMode={form.iconMode}
|
||||
iconId={form.iconId}
|
||||
iconColorMode={form.iconColorMode}
|
||||
iconColor={form.iconColor}
|
||||
iconColorCustom={form.iconColorCustom}
|
||||
onChange={(next) => {
|
||||
if ("distroMode" in next) update("distroMode", next.distroMode);
|
||||
if ("manualDistro" in next) update("manualDistro", next.manualDistro);
|
||||
if ("iconMode" in next) update("iconMode", next.iconMode);
|
||||
if ("iconId" in next) update("iconId", next.iconId);
|
||||
if ("iconColorMode" in next) update("iconColorMode", next.iconColorMode);
|
||||
if ("iconColor" in next) update("iconColor", next.iconColor);
|
||||
if ("iconColorCustom" in next) update("iconColorCustom", next.iconColorCustom);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
159
components/HostDetailsPanel.helpers.ts
Normal file
159
components/HostDetailsPanel.helpers.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import {
|
||||
findProxyProfile,
|
||||
hasIncompleteProxyIdentity,
|
||||
hasMissingProxyIdentity,
|
||||
hasUnreadableProxyCredential,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
} from "../domain/proxyProfiles";
|
||||
import type { Host, Identity, ProxyConfig, ProxyProfile } from "../types";
|
||||
import {
|
||||
LINUX_DISTRO_OPTIONS,
|
||||
NETWORK_DEVICE_OPTIONS,
|
||||
POSIX_PLATFORM_OPTIONS,
|
||||
} from "../domain/host";
|
||||
|
||||
export const parseOptionalPortInput = (value: string): number | undefined =>
|
||||
value ? Number(value) : undefined;
|
||||
|
||||
export const resolvePrimaryProtocolSwitchPort = (
|
||||
currentPort: number | undefined,
|
||||
nextProtocol: "ssh" | "telnet",
|
||||
hasGroupTelnetPortDefault: boolean,
|
||||
hasGroupSshPortDefault: boolean,
|
||||
): number | undefined => {
|
||||
if (nextProtocol === "telnet") {
|
||||
// Don't override if group provides a Telnet default
|
||||
if (hasGroupTelnetPortDefault || hasGroupSshPortDefault) return currentPort;
|
||||
if (currentPort === 22 || currentPort === undefined) return 23;
|
||||
return currentPort;
|
||||
}
|
||||
if (nextProtocol === "ssh") {
|
||||
if (hasGroupSshPortDefault) return currentPort;
|
||||
if (currentPort === 23 || currentPort === undefined) return 22;
|
||||
return currentPort;
|
||||
}
|
||||
return currentPort;
|
||||
};
|
||||
|
||||
export const resolvePrimaryProtocolSavePort = (
|
||||
protocol: Host["protocol"],
|
||||
currentPort: number | undefined,
|
||||
hasGroupSshPortDefault: boolean,
|
||||
hasGroupTelnetPortDefault: boolean,
|
||||
): number | undefined => {
|
||||
if (protocol === "telnet") {
|
||||
if (currentPort !== undefined) return currentPort;
|
||||
if (hasGroupTelnetPortDefault || hasGroupSshPortDefault) return undefined;
|
||||
return 23;
|
||||
}
|
||||
return currentPort ?? (hasGroupSshPortDefault ? undefined : 22);
|
||||
};
|
||||
|
||||
export const resolveDetailsTelnetPort = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): number => {
|
||||
if (host.telnetPort !== undefined && host.telnetPort !== null) return host.telnetPort;
|
||||
if (groupDefaults?.telnetPort !== undefined && groupDefaults.telnetPort !== null) {
|
||||
return groupDefaults.telnetPort;
|
||||
}
|
||||
if (host.protocol === "telnet") {
|
||||
if (host.port !== undefined && host.port !== null) return host.port;
|
||||
if (groupDefaults?.port !== undefined && groupDefaults.port !== null) return groupDefaults.port;
|
||||
}
|
||||
return 23;
|
||||
};
|
||||
|
||||
export const resolveDetailsTelnetUsername = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): string =>
|
||||
host.telnetUsername !== undefined
|
||||
? host.telnetUsername
|
||||
: groupDefaults?.telnetUsername !== undefined
|
||||
? groupDefaults.telnetUsername
|
||||
: host.username ?? groupDefaults?.username ?? "";
|
||||
|
||||
export const resolveDetailsTelnetPassword = (
|
||||
host: Host,
|
||||
groupDefaults?: Partial<GroupConfig>,
|
||||
): string =>
|
||||
host.telnetPassword !== undefined
|
||||
? host.telnetPassword
|
||||
: groupDefaults?.telnetPassword !== undefined
|
||||
? groupDefaults.telnetPassword
|
||||
: host.password ?? groupDefaults?.password ?? "";
|
||||
|
||||
export const prepareTelnetCredentialsForSave = (host: Host): Host =>
|
||||
host.telnetIdentityId
|
||||
? {
|
||||
...host,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
}
|
||||
: host;
|
||||
|
||||
export const LINUX_DISTRO_OPTION_IDS = [
|
||||
...LINUX_DISTRO_OPTIONS,
|
||||
...POSIX_PLATFORM_OPTIONS,
|
||||
...NETWORK_DEVICE_OPTIONS,
|
||||
];
|
||||
|
||||
export type ProxySaveValidationError =
|
||||
| "required"
|
||||
| "port"
|
||||
| "missingSaved"
|
||||
| "missingIdentity"
|
||||
| "incompleteIdentity"
|
||||
| "unreadableIdentity";
|
||||
|
||||
export const validateProxyConfigForSave = ({
|
||||
proxyConfig,
|
||||
proxyProfileId,
|
||||
proxyProfiles,
|
||||
identities,
|
||||
}: {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfileId?: string;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
}): ProxySaveValidationError | null => {
|
||||
const selectedProxyProfile = findProxyProfile(proxyProfileId, proxyProfiles ?? []);
|
||||
if (proxyProfileId && !selectedProxyProfile) return "missingSaved";
|
||||
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
return normalizedProxyConfig.host ? "port" : "required";
|
||||
}
|
||||
|
||||
const effectiveProxyConfig = selectedProxyProfile?.config ?? proxyConfig;
|
||||
if (hasMissingProxyIdentity(effectiveProxyConfig, identities ?? [])) {
|
||||
return "missingIdentity";
|
||||
}
|
||||
if (hasIncompleteProxyIdentity(effectiveProxyConfig, identities ?? [])) {
|
||||
return "incompleteIdentity";
|
||||
}
|
||||
if (hasUnreadableProxyCredential(effectiveProxyConfig, identities ?? [])) {
|
||||
return "unreadableIdentity";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const prepareProxyConfigForSave = (params: {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfileId?: string;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
}): {
|
||||
error?: ProxySaveValidationError;
|
||||
normalizedProxyConfig?: ProxyConfig;
|
||||
} => {
|
||||
const error = validateProxyConfigForSave(params);
|
||||
if (error) return { error };
|
||||
return {
|
||||
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
|
||||
};
|
||||
};
|
||||
551
components/HostDetailsPanel.proxyProfile.test.tsx
Normal file
551
components/HostDetailsPanel.proxyProfile.test.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host, Identity } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import {
|
||||
prepareProxyConfigForSave,
|
||||
prepareTelnetCredentialsForSave,
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
validateProxyConfigForSave,
|
||||
} from "./HostDetailsPanel.helpers.ts";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
id: "host-1",
|
||||
label: "DB",
|
||||
hostname: "db.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
proxyProfileId: "missing-proxy",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const renderHostDetails = (
|
||||
initialData: Host = hostWithMissingProxyProfile,
|
||||
options: { identities?: Identity[] } = {},
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: options.identities ?? [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const findInputByValue = (markup: string, value: string) => {
|
||||
const match = markup.match(new RegExp(`<input(?=[^>]*value="${value}")[^>]*>`));
|
||||
assert.ok(match, `expected input with value ${value}`);
|
||||
return match[0];
|
||||
};
|
||||
|
||||
const classTokens = (markup: string) => {
|
||||
const classMatch = markup.match(/class="([^"]*)"/);
|
||||
assert.ok(classMatch, "expected class attribute");
|
||||
return new Set(classMatch[1].split(/\s+/).filter(Boolean));
|
||||
};
|
||||
|
||||
test("HostDetailsPanel shows a missing saved proxy without undefined fields", () => {
|
||||
const markup = renderHostDetails();
|
||||
|
||||
assert.match(markup, /Missing saved proxy/);
|
||||
assert.doesNotMatch(markup, /undefined:undefined/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel labels command proxy summaries consistently", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: 23,
|
||||
username: "root",
|
||||
password: "ssh-password",
|
||||
telnetUsername: "",
|
||||
telnetPassword: "",
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
|
||||
assert.match(markup, /placeholder="Telnet Username"[^>]*value=""/);
|
||||
assert.match(markup, /placeholder="Telnet Password"[^>]*value=""/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Username"[^>]*value="root"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel shows a selected telnet identity instead of manual telnet fields", () => {
|
||||
const markup = renderHostDetails(
|
||||
{
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
identityId: "ssh-identity",
|
||||
telnetIdentityId: "telnet-identity",
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
{
|
||||
identities: [{
|
||||
id: "ssh-identity",
|
||||
label: "SSH login",
|
||||
username: "ssh-user",
|
||||
authMethod: "password",
|
||||
password: "ssh-password",
|
||||
created: 1,
|
||||
}, {
|
||||
id: "telnet-identity",
|
||||
label: "Telnet login",
|
||||
username: "telnet-user",
|
||||
authMethod: "password",
|
||||
password: "telnet-password",
|
||||
created: 2,
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /telnet-user - Telnet login/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Username"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"/);
|
||||
});
|
||||
|
||||
test("prepareTelnetCredentialsForSave preserves selected telnet identity and clears manual telnet fields", () => {
|
||||
const saved = prepareTelnetCredentialsForSave({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
identityId: "ssh-identity",
|
||||
telnetIdentityId: "telnet-identity",
|
||||
telnetUsername: "stale-telnet-user",
|
||||
telnetPassword: "stale-telnet-password",
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
|
||||
assert.equal(saved.identityId, "ssh-identity");
|
||||
assert.equal(saved.telnetIdentityId, "telnet-identity");
|
||||
assert.equal(saved.telnetUsername, undefined);
|
||||
assert.equal(saved.telnetPassword, undefined);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel disables save when hostname is whitespace only", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
hostname: " ",
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
|
||||
assert.match(markup, /<button[^>]*disabled=""[^>]*aria-label="Save"/);
|
||||
assert.match(markup, />Save<\/button>/);
|
||||
assert.match(markup, /<button[^>]*disabled=""[^>]*>Save<\/button>/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel gives the telnet port field the same roomy layout as SSH", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: 2325,
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
|
||||
const telnetMarkup = markup.slice(markup.indexOf("Telnet on"));
|
||||
const wrapperMatch = telnetMarkup.match(/<div class="([^"]*w-1\/2[^"]*)"/);
|
||||
assert.ok(wrapperMatch, "expected telnet port wrapper");
|
||||
const wrapperClasses = new Set(wrapperMatch[1].split(/\s+/).filter(Boolean));
|
||||
assert.ok(wrapperClasses.has("ml-auto"));
|
||||
assert.ok(wrapperClasses.has("w-1/2"));
|
||||
assert.ok(wrapperClasses.has("min-w-0"));
|
||||
assert.ok(wrapperClasses.has("justify-end"));
|
||||
const telnetPortInput = findInputByValue(markup, "2325");
|
||||
const inputClasses = classTokens(telnetPortInput);
|
||||
assert.ok(inputClasses.has("flex-1"));
|
||||
assert.ok(inputClasses.has("min-w-0"));
|
||||
assert.ok(inputClasses.has("text-center"));
|
||||
assert.equal(inputClasses.has("w-16"), false);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel displays inherited telnet port before falling back to 23", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: undefined,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(findInputByValue(markup, "2325"), /type="number"/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel uses group telnet port instead of ssh port for optional telnet", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: 2222,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const telnetMarkup = markup.slice(markup.indexOf("Telnet on"));
|
||||
assert.match(findInputByValue(telnetMarkup, "2325"), /type="number"/);
|
||||
assert.doesNotMatch(telnetMarkup, /value="2222"/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel displays inherited telnet credentials", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
username: "ssh-user",
|
||||
password: "ssh-password",
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPassword: "group-telnet-password",
|
||||
}],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /placeholder="Telnet Username"[^>]*value="group-telnet-user"/);
|
||||
assert.match(markup, /placeholder="Telnet Password"[^>]*value="group-telnet-password"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Username"[^>]*value="ssh-user"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
|
||||
});
|
||||
|
||||
test("parseOptionalPortInput clears empty port values", () => {
|
||||
assert.equal(parseOptionalPortInput(""), undefined);
|
||||
assert.equal(parseOptionalPortInput("2325"), 2325);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects missing proxy identities", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
identities: [],
|
||||
}),
|
||||
"missingIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects missing identities inside saved proxy profiles", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyProfileId: "profile-1",
|
||||
proxyProfiles: [{
|
||||
id: "profile-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
createdAt: 1,
|
||||
}],
|
||||
identities: [],
|
||||
}),
|
||||
"missingIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects incomplete proxy identities", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
"incompleteIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects unreadable proxy identity passwords", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
"unreadableIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyConfigForSave returns a normalized proxy config for save handlers", () => {
|
||||
assert.deepEqual(
|
||||
prepareProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: " proxy.example.com ",
|
||||
port: "3128" as never,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
username: " proxy-user ",
|
||||
password: "proxy-secret",
|
||||
},
|
||||
identities: [],
|
||||
}),
|
||||
{
|
||||
normalizedProxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyConfigForSave returns identity errors before save handlers build output", () => {
|
||||
assert.deepEqual(
|
||||
prepareProxyConfigForSave({
|
||||
proxyProfileId: "profile-1",
|
||||
proxyProfiles: [{
|
||||
id: "profile-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
createdAt: 1,
|
||||
}],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
{ error: "incompleteIdentity" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolvePrimaryProtocolSwitchPort only migrates opposite protocol defaults", () => {
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(23, "ssh", false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(2222, "telnet", false, false), 2222);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(2323, "ssh", false, false), 2323);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(undefined, "telnet", false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(undefined, "ssh", false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, true), 22);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", true, false), 22);
|
||||
});
|
||||
|
||||
test("resolvePrimaryProtocolSavePort falls back to telnet default for primary telnet", () => {
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", 2323, false, false), 2323);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("ssh", undefined, false, false), 22);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("ssh", undefined, true, false), undefined);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, false, true), undefined);
|
||||
assert.equal(resolvePrimaryProtocolSavePort("telnet", undefined, true, false), undefined);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel does not offer to disable telnet when telnet is the primary protocol", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: 23,
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
const telnetHeader = markup.match(/Telnet on[\s\S]*?Credentials/);
|
||||
|
||||
assert.ok(telnetHeader);
|
||||
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel shows color and icon controls in the connection settings", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
distroMode: "manual",
|
||||
manualDistro: "linux",
|
||||
iconColorMode: "manual",
|
||||
iconColor: "blue",
|
||||
});
|
||||
|
||||
assert.match(markup, /Color & Icon/);
|
||||
assert.match(markup, /Manual icon/);
|
||||
assert.match(markup, /Generic Linux/);
|
||||
assert.match(markup, /Icon color/);
|
||||
assert.match(markup, /Blue/);
|
||||
assert.match(markup, /IP or Hostname/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps empty notes collapsed below connection settings", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
notes: "",
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Show notes editor"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Hardware, project, customer, region, role\.\.\."/);
|
||||
assert.ok(
|
||||
markup.indexOf("IP or Hostname") < markup.indexOf("Notes"),
|
||||
"expected notes to render after connection settings",
|
||||
);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel expands notes when the host already has notes", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
notes: "Runs nightly backups",
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Hide notes editor"/);
|
||||
assert.match(markup, /Runs nightly backups/);
|
||||
assert.match(markup, /placeholder="Hardware, project, customer, region, role\.\.\."/);
|
||||
assert.ok(
|
||||
markup.indexOf("IP or Hostname") < markup.indexOf("Notes"),
|
||||
"expected notes to render after connection settings",
|
||||
);
|
||||
});
|
||||
21
components/HostDetailsPanel.sshAgentStatus.test.ts
Normal file
21
components/HostDetailsPanel.sshAgentStatus.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./HostDetailsPanel.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('login and forwarding agent availability are checked independently', () => {
|
||||
assert.equal(source.match(/let cancelled = false/g)?.length, 2);
|
||||
assert.match(source, /if \(!cancelled\) setSshAgentStatus\(status\)/);
|
||||
assert.match(source, /if \(!cancelled\) setSshForwardingAgentStatus\(status\)/);
|
||||
assert.match(source, /if \(form\.agentForwarding \|\| form\.useSshAgent === true\) \{\s*void checkSshAgent\(\{\s*identityAgent: form\.useSshAgent === true \? form\.identityAgent : undefined,\s*hostname:/);
|
||||
assert.match(source, /if \(form\.agentForwarding\) \{\s*void checkSshAgent\(\{\s*identityAgent: form\.identityAgent,\s*agentForwarding: true,/);
|
||||
assert.equal(source.match(/return \(\) => \{\s*cancelled = true/g)?.length, 2);
|
||||
assert.match(source, /sshForwardingAgentStatus=\{sshForwardingAgentStatus\}/);
|
||||
});
|
||||
|
||||
test('inherited identity actions use the resolved authentication username', () => {
|
||||
assert.match(source, /const effectiveAuth = useMemo\(\(\) => resolveHostAuth/);
|
||||
assert.match(source, /detachEffectiveHostIdentity\(prev, effectiveAuth\.username\)/);
|
||||
assert.match(source, /effectiveUsername=\{effectiveAuth\.username\}/);
|
||||
});
|
||||
1326
components/HostDetailsPanel.tsx
Normal file
1326
components/HostDetailsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
132
components/HostIconPicker.test.tsx
Normal file
132
components/HostIconPicker.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { HostIconPicker } from "./HostIconPicker.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const distroOptions = [
|
||||
{
|
||||
value: "linux",
|
||||
label: "Generic Linux",
|
||||
icon: "/distro/linux.svg",
|
||||
bgClass: "bg-[#333333]",
|
||||
},
|
||||
{
|
||||
value: "ubuntu",
|
||||
label: "Ubuntu",
|
||||
icon: "/distro/ubuntu.svg",
|
||||
bgClass: "bg-[#E95420]",
|
||||
},
|
||||
];
|
||||
|
||||
const hostIconPickerSource = readFileSync(new URL("./HostIconPicker.tsx", import.meta.url), "utf8");
|
||||
|
||||
const renderPicker = (props: Partial<React.ComponentProps<typeof HostIconPicker>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<HostIconPicker
|
||||
distroMode={props.distroMode}
|
||||
manualDistro={props.manualDistro}
|
||||
effectiveDistro={props.effectiveDistro}
|
||||
distroOptions={props.distroOptions || distroOptions}
|
||||
getDistroOptionLabel={props.getDistroOptionLabel || ((value) => distroOptions.find((option) => option.value === value)?.label || "Unknown")}
|
||||
iconMode={props.iconMode}
|
||||
iconId={props.iconId}
|
||||
iconColorMode={props.iconColorMode}
|
||||
iconColor={props.iconColor}
|
||||
iconColorCustom={props.iconColorCustom}
|
||||
manualIconMenuOpen={props.manualIconMenuOpen}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("HostIconPicker renders automatic source and automatic color by default", () => {
|
||||
const markup = renderPicker({ effectiveDistro: "ubuntu" });
|
||||
|
||||
assert.match(markup, /Source/);
|
||||
assert.match(markup, /Auto-detect/);
|
||||
assert.match(markup, /Current/);
|
||||
assert.match(markup, /Ubuntu/);
|
||||
assert.match(markup, /Icon color/);
|
||||
assert.match(markup, /Automatic/);
|
||||
assert.match(markup, /Current color/);
|
||||
assert.doesNotMatch(markup, /Brand/);
|
||||
assert.doesNotMatch(markup, /Custom color/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders a single manual icon dropdown in manual source mode", () => {
|
||||
const markup = renderPicker({ distroMode: "manual", manualDistro: "linux", manualIconMenuOpen: true });
|
||||
|
||||
assert.match(markup, /Manual icon/);
|
||||
assert.match(markup, /Generic Linux/);
|
||||
assert.match(markup, /aria-label="Manual icon"/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders built-in type icons when a custom type is selected", () => {
|
||||
const markup = renderPicker({ distroMode: "manual", iconMode: "custom", iconId: "database", manualIconMenuOpen: true });
|
||||
|
||||
assert.match(markup, /Database/);
|
||||
assert.match(markup, /Current color/);
|
||||
assert.match(markup, /background-color:#0891B2/);
|
||||
});
|
||||
|
||||
test("HostIconPicker shows the automatic brand color preview", () => {
|
||||
const markup = renderPicker({ distroMode: "manual", manualDistro: "ubuntu" });
|
||||
|
||||
assert.match(markup, /Current color/);
|
||||
assert.match(markup, /bg-\[#E95420\]/);
|
||||
assert.match(markup, /Ubuntu/);
|
||||
});
|
||||
|
||||
test("HostIconPicker ignores stale colors when color mode is automatic", () => {
|
||||
const markup = renderPicker({
|
||||
distroMode: "manual",
|
||||
iconMode: "custom",
|
||||
iconId: "database",
|
||||
iconColorMode: "auto",
|
||||
iconColor: "violet",
|
||||
iconColorCustom: "#12ABEF",
|
||||
});
|
||||
|
||||
assert.match(markup, /Automatic/);
|
||||
assert.match(markup, /Current color/);
|
||||
assert.match(markup, /Database/);
|
||||
assert.match(markup, /background-color:#0891B2/);
|
||||
assert.doesNotMatch(markup, /#12ABEF/);
|
||||
assert.doesNotMatch(markup, /Violet/);
|
||||
});
|
||||
|
||||
test("HostIconPicker keeps brand/type tabs inside the manual icon dropdown menu", () => {
|
||||
assert.match(hostIconPickerSource, /<SelectContent className="min-w-\[16rem\]" hideScrollButtons>[\s\S]*role="tab"[\s\S]*hostDetails\.icon\.tab\.brand/);
|
||||
assert.match(hostIconPickerSource, /<SelectContent className="min-w-\[16rem\]" hideScrollButtons>[\s\S]*hostDetails\.icon\.tab\.type/);
|
||||
assert.doesNotMatch(hostIconPickerSource, /<TabsList/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders preset and custom color choices in manual color mode", () => {
|
||||
const markup = renderPicker({ iconColorMode: "manual", iconColor: "violet" });
|
||||
|
||||
assert.match(markup, /Manual/);
|
||||
assert.match(markup, /Violet/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders a custom hex color input", () => {
|
||||
const markup = renderPicker({ iconColorMode: "manual", iconColorCustom: "#12ABEF" });
|
||||
|
||||
assert.match(markup, /Custom color/);
|
||||
assert.match(markup, /#12ABEF/);
|
||||
assert.match(markup, /type="color"/);
|
||||
});
|
||||
|
||||
test("HostIconPicker does not expose image upload", () => {
|
||||
const markup = renderPicker({ distroMode: "manual", iconMode: "custom", iconId: "database" });
|
||||
|
||||
assert.doesNotMatch(markup, /upload/i);
|
||||
assert.doesNotMatch(markup, /choose file/i);
|
||||
});
|
||||
401
components/HostIconPicker.tsx
Normal file
401
components/HostIconPicker.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
DEFAULT_HOST_ICON_COLOR,
|
||||
DEFAULT_HOST_ICON_ID,
|
||||
HOST_ICON_COLORS,
|
||||
HOST_ICON_IDS,
|
||||
isHostIconColorId,
|
||||
isHostIconCustomColor,
|
||||
isHostIconId,
|
||||
resolveHostIconDefaultColorHex,
|
||||
} from "../domain/hostIcon";
|
||||
import type { Host } from "../domain/models";
|
||||
import type { HostIconColorId, HostIconColorMode, HostIconId, HostIconMode } from "../domain/models";
|
||||
import { cn } from "../lib/utils";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
|
||||
type DistroOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
bgClass: string;
|
||||
};
|
||||
|
||||
type HostIconPickerProps = {
|
||||
previewHost?: Host;
|
||||
distroMode?: "auto" | "manual";
|
||||
manualDistro?: string;
|
||||
effectiveDistro?: string;
|
||||
distroOptions: DistroOption[];
|
||||
getDistroOptionLabel: (value?: string) => string;
|
||||
iconMode?: HostIconMode;
|
||||
iconId?: HostIconId;
|
||||
iconColorMode?: HostIconColorMode;
|
||||
iconColor?: HostIconColorId;
|
||||
iconColorCustom?: string;
|
||||
manualIconMenuOpen?: boolean;
|
||||
onChange: (next: {
|
||||
distroMode?: "auto" | "manual";
|
||||
manualDistro?: string;
|
||||
iconMode?: HostIconMode;
|
||||
iconId?: HostIconId;
|
||||
iconColorMode?: HostIconColorMode;
|
||||
iconColor?: HostIconColorId;
|
||||
iconColorCustom?: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const renderBrandOption = (option: DistroOption) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img src={option.icon} alt={option.label} className="h-3 w-3 object-contain invert brightness-0" />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTypeOption = (iconId: HostIconId, label: string, colorHex: string) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px] text-white"
|
||||
style={{ backgroundColor: colorHex }}
|
||||
>
|
||||
{renderHostIconGlyph(iconId, "h-3 w-3")}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBrandColorPreview = (option: DistroOption, label: string) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn("h-4 w-4 shrink-0 rounded-full border border-border/70", option.bgClass)} />
|
||||
<span className="truncate whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTypeColorPreview = (label: string, colorHex: string) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border border-border/70" style={{ backgroundColor: colorHex }} />
|
||||
<span className="truncate whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const colorHexForId = (colorId?: HostIconColorId) =>
|
||||
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
|
||||
|
||||
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
|
||||
previewHost,
|
||||
distroMode,
|
||||
manualDistro,
|
||||
effectiveDistro,
|
||||
distroOptions,
|
||||
getDistroOptionLabel,
|
||||
iconMode,
|
||||
iconId,
|
||||
iconColorMode,
|
||||
iconColor,
|
||||
iconColorCustom,
|
||||
manualIconMenuOpen,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const manualSource = distroMode === "manual" || iconMode === "custom";
|
||||
const selectedBrandId = manualDistro || effectiveDistro || "linux";
|
||||
const selectedBrand = distroOptions.find((option) => option.value === selectedBrandId);
|
||||
const selectedIconId = isHostIconId(iconId) ? iconId : DEFAULT_HOST_ICON_ID;
|
||||
const selectedIconTab = iconMode === "custom" ? "type" : "brand";
|
||||
const manualColor = iconColorMode === "manual" ||
|
||||
(iconColorMode !== "auto" && (isHostIconColorId(iconColor) || isHostIconCustomColor(iconColorCustom)));
|
||||
const selectedPresetColor = isHostIconColorId(iconColor) ? iconColor : DEFAULT_HOST_ICON_COLOR;
|
||||
const selectedCustomColor = isHostIconCustomColor(iconColorCustom) ? iconColorCustom : colorHexForId(selectedPresetColor);
|
||||
const selectedColorValue = isHostIconCustomColor(iconColorCustom) ? "custom" : selectedPresetColor;
|
||||
const manualPreviewColor = isHostIconCustomColor(iconColorCustom)
|
||||
? selectedCustomColor
|
||||
: colorHexForId(selectedPresetColor);
|
||||
const selectedTypePreviewColor = manualColor
|
||||
? manualPreviewColor
|
||||
: resolveHostIconDefaultColorHex(selectedIconId);
|
||||
const autoColorPreviewLabel = selectedIconTab === "type"
|
||||
? t(`hostDetails.icon.option.${selectedIconId}`)
|
||||
: getDistroOptionLabel(selectedBrandId);
|
||||
const selectedManualIconValue = selectedIconTab === "type" ? `type:${selectedIconId}` : `brand:${selectedBrandId}`;
|
||||
const [customColorDraft, setCustomColorDraft] = React.useState(selectedCustomColor);
|
||||
const [iconMenuTab, setIconMenuTab] = React.useState<"brand" | "type">(selectedIconTab);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCustomColorDraft(selectedCustomColor);
|
||||
}, [selectedCustomColor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIconMenuTab(selectedIconTab);
|
||||
}, [selectedIconTab]);
|
||||
|
||||
const setSource = (value: "auto" | "manual") => {
|
||||
if (value === "auto") {
|
||||
onChange({ distroMode: "auto", iconMode: undefined, iconId: undefined });
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
distroMode: "manual",
|
||||
manualDistro: selectedBrandId,
|
||||
iconMode: undefined,
|
||||
iconId: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const setManualIcon = (value: string) => {
|
||||
if (value.startsWith("type:")) {
|
||||
const nextIconId = value.slice("type:".length);
|
||||
if (isHostIconId(nextIconId)) {
|
||||
onChange({ distroMode: "manual", iconMode: "custom", iconId: nextIconId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("brand:")) {
|
||||
onChange({
|
||||
distroMode: "manual",
|
||||
manualDistro: value.slice("brand:".length),
|
||||
iconMode: undefined,
|
||||
iconId: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const switchIconMenuTab = (nextTab: "brand" | "type") => {
|
||||
setIconMenuTab(nextTab);
|
||||
};
|
||||
|
||||
const setColorMode = (value: "auto" | "manual") => {
|
||||
if (value === "auto") {
|
||||
onChange({ iconColorMode: "auto", iconColor: undefined, iconColorCustom: undefined });
|
||||
return;
|
||||
}
|
||||
onChange({ iconColorMode: "manual", iconColor: selectedPresetColor, iconColorCustom: undefined });
|
||||
};
|
||||
|
||||
const setColorChoice = (value: string) => {
|
||||
if (value === "custom") {
|
||||
onChange({ iconColorMode: "manual", iconColor: undefined, iconColorCustom: selectedCustomColor });
|
||||
return;
|
||||
}
|
||||
if (isHostIconColorId(value)) {
|
||||
onChange({ iconColorMode: "manual", iconColor: value, iconColorCustom: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const setCustomColor = (value: string) => {
|
||||
setCustomColorDraft(value);
|
||||
if (isHostIconCustomColor(value)) {
|
||||
onChange({
|
||||
iconColorMode: "manual",
|
||||
iconColor: undefined,
|
||||
iconColorCustom: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{previewHost ? (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl border border-border/60 bg-secondary/20 py-5"
|
||||
aria-label={t("hostDetails.icon.preview")}
|
||||
>
|
||||
<DistroAvatar
|
||||
host={previewHost}
|
||||
fallback={
|
||||
previewHost.label?.slice(0, 2).toUpperCase()
|
||||
|| previewHost.hostname?.slice(0, 2).toUpperCase()
|
||||
|| "H"
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.source")}</span>
|
||||
<Select value={manualSource ? "manual" : "auto"} onValueChange={(value) => setSource(value as "auto" | "manual")}>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.source")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{manualSource ? t("hostDetails.distro.mode.manual") : t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{manualSource ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.manualLabel")}</span>
|
||||
<Select
|
||||
value={selectedManualIconValue}
|
||||
onValueChange={setManualIcon}
|
||||
{...(manualIconMenuOpen === undefined ? {} : { open: manualIconMenuOpen })}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.manualLabel")}>
|
||||
{selectedIconTab === "type"
|
||||
? renderTypeOption(selectedIconId, t(`hostDetails.icon.option.${selectedIconId}`), selectedTypePreviewColor)
|
||||
: selectedBrand
|
||||
? renderBrandOption(selectedBrand)
|
||||
: <SelectValue placeholder={t("hostDetails.distro.unknown")} />}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[16rem]" hideScrollButtons>
|
||||
<div className="sticky top-0 z-10 bg-popover px-1 pb-1">
|
||||
<div className="grid h-8 grid-cols-2 rounded-md bg-muted/50 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconMenuTab === "brand"}
|
||||
className={cn(
|
||||
"rounded-sm text-xs font-medium transition-colors",
|
||||
iconMenuTab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
switchIconMenuTab("brand");
|
||||
}}
|
||||
>
|
||||
{t("hostDetails.icon.tab.brand")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconMenuTab === "type"}
|
||||
className={cn(
|
||||
"rounded-sm text-xs font-medium transition-colors",
|
||||
iconMenuTab === "type" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
switchIconMenuTab("type");
|
||||
}}
|
||||
>
|
||||
{t("hostDetails.icon.tab.type")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{iconMenuTab === "brand"
|
||||
? distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={`brand:${option.value}`}>
|
||||
{renderBrandOption(option)}
|
||||
</SelectItem>
|
||||
))
|
||||
: HOST_ICON_IDS.map((optionIconId) => (
|
||||
<SelectItem key={optionIconId} value={`type:${optionIconId}`}>
|
||||
{renderTypeOption(
|
||||
optionIconId,
|
||||
t(`hostDetails.icon.option.${optionIconId}`),
|
||||
manualColor ? manualPreviewColor : resolveHostIconDefaultColorHex(optionIconId),
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveDistro ? getDistroOptionLabel(effectiveDistro) : t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.colorLabel")}</span>
|
||||
<Select value={manualColor ? "manual" : "auto"} onValueChange={(value) => setColorMode(value as "auto" | "manual")}>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.colorLabel")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{manualColor ? t("hostDetails.icon.colorManual") : t("hostDetails.icon.colorAuto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.icon.colorAuto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.icon.colorManual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{manualColor ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.colorChoice")}</span>
|
||||
<Select value={selectedColorValue} onValueChange={setColorChoice}>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.colorChoice")}>
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border border-border/70" style={{ backgroundColor: manualPreviewColor }} />
|
||||
<span className="truncate whitespace-nowrap">
|
||||
{selectedColorValue === "custom"
|
||||
? t("hostDetails.icon.color.custom")
|
||||
: t(`hostDetails.icon.color.${selectedPresetColor}`)}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOST_ICON_COLORS.map((color) => (
|
||||
<SelectItem key={color.id} value={color.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border border-border/70" style={{ backgroundColor: color.hex }} />
|
||||
<span>{t(`hostDetails.icon.color.${color.id}`)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">{t("hostDetails.icon.color.custom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.autoColorLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{selectedIconTab === "type"
|
||||
? renderTypeColorPreview(autoColorPreviewLabel, selectedTypePreviewColor)
|
||||
: selectedBrand
|
||||
? renderBrandColorPreview(selectedBrand, autoColorPreviewLabel)
|
||||
: renderTypeColorPreview(t("hostDetails.distro.unknown"), colorHexForId(DEFAULT_HOST_ICON_COLOR))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{manualColor && selectedColorValue === "custom" && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={isHostIconCustomColor(customColorDraft) ? customColorDraft : selectedCustomColor}
|
||||
onChange={(event) => setCustomColor(event.target.value)}
|
||||
className="h-8 w-12 shrink-0 cursor-pointer p-1"
|
||||
aria-label={t("hostDetails.icon.color.custom")}
|
||||
/>
|
||||
<Input
|
||||
value={customColorDraft}
|
||||
onChange={(event) => setCustomColor(event.target.value)}
|
||||
className="h-8 font-mono text-xs"
|
||||
aria-label={t("hostDetails.icon.color.custom")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
480
components/HostTreeView.test.tsx
Normal file
480
components/HostTreeView.test.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import type { GroupConfig, GroupNode, Host } from "../types.ts";
|
||||
import {
|
||||
buildVisibleHostTreeItems,
|
||||
getHostTreeDisplayDetails,
|
||||
HostTreeView,
|
||||
useHostTreeExpandedPaths,
|
||||
} from "./HostTreeView.tsx";
|
||||
|
||||
const baseHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Router",
|
||||
hostname: "router.example.com",
|
||||
username: "ssh-user",
|
||||
port: 2222,
|
||||
protocol: "telnet",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const installLocalStorageMock = () => {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test("HostTreeView display details include inherited telnet defaults", () => {
|
||||
const host: Host = {
|
||||
...baseHost,
|
||||
group: "network",
|
||||
username: "ssh-user",
|
||||
port: 2222,
|
||||
telnetUsername: undefined,
|
||||
telnetPort: undefined,
|
||||
};
|
||||
const groupConfigs: GroupConfig[] = [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPort: 2325,
|
||||
}];
|
||||
|
||||
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
|
||||
protocol: "telnet",
|
||||
username: "group-telnet-user",
|
||||
port: 2325,
|
||||
});
|
||||
});
|
||||
|
||||
test("HostTreeView display details keep explicit cleared telnet username", () => {
|
||||
const host: Host = {
|
||||
...baseHost,
|
||||
group: "network",
|
||||
telnetUsername: "",
|
||||
};
|
||||
const groupConfigs: GroupConfig[] = [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPort: 2325,
|
||||
}];
|
||||
|
||||
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
|
||||
protocol: "telnet",
|
||||
username: "",
|
||||
port: 2325,
|
||||
});
|
||||
});
|
||||
|
||||
test("HostTreeView renders the host edit action beside the host label", () => {
|
||||
installLocalStorageMock();
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[]}
|
||||
hosts={[{
|
||||
...baseHost,
|
||||
notes: "Maintenance notes",
|
||||
tags: ["edge"],
|
||||
}]}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelIndex = markup.indexOf("Router");
|
||||
const editButtonIndex = markup.indexOf('data-host-tree-host-edit-button="host-1"');
|
||||
const notesIndex = markup.indexOf('aria-label="Host notes"', labelIndex);
|
||||
const protocolIndex = markup.indexOf("TELNET", labelIndex);
|
||||
|
||||
assert.ok(labelIndex >= 0);
|
||||
assert.ok(editButtonIndex > labelIndex);
|
||||
assert.ok(notesIndex > editButtonIndex);
|
||||
assert.ok(protocolIndex > notesIndex);
|
||||
});
|
||||
|
||||
test("HostTreeView renders the group edit action beside the group label", () => {
|
||||
installLocalStorageMock();
|
||||
|
||||
const groupNode: GroupNode = {
|
||||
name: "Production",
|
||||
path: "production",
|
||||
children: {},
|
||||
hosts: [],
|
||||
totalHostCount: 2,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[groupNode]}
|
||||
hosts={[]}
|
||||
expandedPaths={new Set<string>()}
|
||||
onTogglePath={() => undefined}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelIndex = markup.indexOf("Production");
|
||||
const editButtonIndex = markup.indexOf('data-host-tree-group-edit-button="production"');
|
||||
const countIndex = markup.indexOf(">2<", editButtonIndex);
|
||||
|
||||
assert.ok(labelIndex >= 0);
|
||||
assert.ok(editButtonIndex > labelIndex);
|
||||
assert.ok(countIndex > editButtonIndex);
|
||||
});
|
||||
|
||||
test("HostTreeView shows selected groups in multi-select mode", () => {
|
||||
installLocalStorageMock();
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[{
|
||||
name: "Production",
|
||||
path: "production",
|
||||
children: {},
|
||||
hosts: [],
|
||||
}]}
|
||||
hosts={[]}
|
||||
expandedPaths={new Set<string>()}
|
||||
onTogglePath={() => undefined}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
isMultiSelectMode
|
||||
selectedGroupPaths={new Set(["production"])}
|
||||
toggleGroupSelection={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /data-selected="true"/);
|
||||
assert.match(markup, /data-group-path="production"/);
|
||||
assert.match(markup, /role="tree"/);
|
||||
assert.match(markup, /aria-multiselectable="true"/);
|
||||
assert.match(markup, /role="treeitem"/);
|
||||
assert.match(markup, /aria-selected="true"/);
|
||||
assert.match(markup, /data-tree-item-key="group:production"/);
|
||||
assert.match(markup, /data-tree-depth="0"/);
|
||||
assert.match(markup, /aria-level="1"/);
|
||||
assert.match(markup, /tabindex="0"/);
|
||||
assert.doesNotMatch(markup, /aria-expanded=/);
|
||||
assert.doesNotMatch(markup, /data-host-tree-group-edit-button="production"/);
|
||||
});
|
||||
|
||||
test("HostTreeView exposes selected hosts to keyboard and assistive technology", () => {
|
||||
installLocalStorageMock();
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[]}
|
||||
hosts={[baseHost]}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
isMultiSelectMode
|
||||
selectedHostIds={new Set([baseHost.id])}
|
||||
toggleHostSelection={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /role="tree"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*role="treeitem"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*aria-selected="true"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-item-key="host:host-1"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
|
||||
});
|
||||
|
||||
test("HostTreeView exposes only one tree item in the tab order", () => {
|
||||
installLocalStorageMock();
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[{
|
||||
name: "Production",
|
||||
path: "production",
|
||||
children: {},
|
||||
hosts: [],
|
||||
}]}
|
||||
hosts={[baseHost]}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(markup.match(/tabindex="0"/g)?.length, 1);
|
||||
assert.match(
|
||||
markup,
|
||||
/<button(?=[^>]*data-host-tree-group-edit-button="production")(?=[^>]*tabindex="-1")[^>]*>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("HostTreeView flattens only expanded group descendants", () => {
|
||||
const childHost = { ...baseHost, id: "child-host", group: "production" };
|
||||
const groupTree = [{
|
||||
name: "Production",
|
||||
path: "production",
|
||||
children: {},
|
||||
hosts: [childHost],
|
||||
}];
|
||||
|
||||
assert.deepEqual(
|
||||
buildVisibleHostTreeItems({
|
||||
groupTree,
|
||||
ungroupedHosts: [baseHost],
|
||||
expandedPaths: new Set(),
|
||||
sortMode: "az",
|
||||
groupConfigs: [],
|
||||
}).map((item) => item.key),
|
||||
["group:production", "host:host-1"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildVisibleHostTreeItems({
|
||||
groupTree,
|
||||
ungroupedHosts: [baseHost],
|
||||
expandedPaths: new Set(["production"]),
|
||||
sortMode: "az",
|
||||
groupConfigs: [],
|
||||
}).map((item) => item.key),
|
||||
["group:production", "host:child-host", "host:host-1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("HostTreeView keeps an explicit collapsed state after search auto-expands groups", () => {
|
||||
installLocalStorageMock();
|
||||
const childHost = { ...baseHost, id: "child-host", group: "production" };
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[{
|
||||
name: "Production",
|
||||
path: "production",
|
||||
children: {},
|
||||
hosts: [childHost],
|
||||
}]}
|
||||
hosts={[childHost]}
|
||||
expandedPaths={new Set()}
|
||||
autoExpandGroupsKey="router"
|
||||
onTogglePath={() => undefined}
|
||||
onExpandAll={() => undefined}
|
||||
onCollapseAll={() => undefined}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /data-group-path="production"[^>]*aria-expanded="false"/);
|
||||
assert.doesNotMatch(markup, /data-host-id="child-host"/);
|
||||
});
|
||||
|
||||
test("HostTreeView search expansion is temporary and remains collapsible", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let persistentMutationCount = 0;
|
||||
let autoExpandGroupsKey: string | undefined = "router";
|
||||
let allGroupPaths = ["production"];
|
||||
let expandedPaths = new Set<string>();
|
||||
let collapseAll = () => undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const Probe = () => {
|
||||
const state = useHostTreeExpandedPaths({
|
||||
persistentExpandedPaths: new Set(),
|
||||
allGroupPaths,
|
||||
autoExpandGroupsKey,
|
||||
onTogglePath: () => { persistentMutationCount++; },
|
||||
onExpandAll: () => { persistentMutationCount++; },
|
||||
onCollapseAll: () => { persistentMutationCount++; },
|
||||
});
|
||||
expandedPaths = state.expandedPaths;
|
||||
collapseAll = state.collapseAll;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
assert.equal(expandedPaths.has("production"), true);
|
||||
|
||||
await act(async () => {
|
||||
collapseAll();
|
||||
});
|
||||
assert.equal(expandedPaths.has("production"), false);
|
||||
assert.equal(persistentMutationCount, 0);
|
||||
|
||||
await act(async () => {
|
||||
allGroupPaths = ["production", "staging"];
|
||||
renderer!.update(React.createElement(Probe));
|
||||
});
|
||||
assert.equal(expandedPaths.has("production"), false);
|
||||
assert.equal(expandedPaths.has("staging"), true);
|
||||
assert.equal(persistentMutationCount, 0);
|
||||
|
||||
await act(async () => {
|
||||
autoExpandGroupsKey = undefined;
|
||||
renderer!.update(React.createElement(Probe));
|
||||
});
|
||||
assert.equal(expandedPaths.has("production"), false);
|
||||
assert.equal(persistentMutationCount, 0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test("HostTreeView virtualizes an 8,000-host tree", () => {
|
||||
installLocalStorageMock();
|
||||
const hosts = Array.from({ length: 8000 }, (_, index) => ({
|
||||
...baseHost,
|
||||
id: `host-${index}`,
|
||||
label: `Host ${String(index).padStart(4, "0")}`,
|
||||
}));
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostTreeView
|
||||
groupTree={[]}
|
||||
hosts={hosts}
|
||||
onConnect={() => undefined}
|
||||
onEditHost={() => undefined}
|
||||
onDuplicateHost={() => undefined}
|
||||
onDeleteHost={() => undefined}
|
||||
onCopyCredentials={() => undefined}
|
||||
onNewGroup={() => undefined}
|
||||
onRenameGroup={() => undefined}
|
||||
onEditGroup={() => undefined}
|
||||
onDeleteGroup={() => undefined}
|
||||
moveHostToGroup={() => undefined}
|
||||
moveGroup={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const renderedHosts = markup.match(/data-host-id=/g)?.length ?? 0;
|
||||
assert.ok(renderedHosts > 0);
|
||||
assert.ok(renderedHosts < 100);
|
||||
assert.match(markup, /data-vault-virtual-tree="true"/);
|
||||
assert.match(markup, /height:320000px/);
|
||||
// Flat ungrouped hosts share one sibling set at the root level.
|
||||
assert.match(markup, /aria-setsize="8000"/);
|
||||
assert.match(markup, /aria-posinset="1"/);
|
||||
assert.match(markup, /role="treeitem"/);
|
||||
});
|
||||
|
||||
test("HostTreeView announces sibling position within each tree level", () => {
|
||||
installLocalStorageMock();
|
||||
const groupTree: GroupNode[] = [{
|
||||
name: "prod",
|
||||
path: "prod",
|
||||
hosts: [
|
||||
{ ...baseHost, id: "host-a", label: "A", group: "prod" },
|
||||
{ ...baseHost, id: "host-b", label: "B", group: "prod" },
|
||||
],
|
||||
children: {
|
||||
east: {
|
||||
name: "east",
|
||||
path: "prod/east",
|
||||
hosts: [{ ...baseHost, id: "host-c", label: "C", group: "prod/east" }],
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
}];
|
||||
const hosts = [
|
||||
{ ...baseHost, id: "host-a", label: "A", group: "prod" },
|
||||
{ ...baseHost, id: "host-b", label: "B", group: "prod" },
|
||||
{ ...baseHost, id: "host-c", label: "C", group: "prod/east" },
|
||||
{ ...baseHost, id: "host-root", label: "Root", group: "" },
|
||||
];
|
||||
const items = buildVisibleHostTreeItems({
|
||||
groupTree,
|
||||
ungroupedHosts: hosts.filter((host) => !host.group),
|
||||
expandedPaths: new Set(["prod", "prod/east"]),
|
||||
sortMode: "az",
|
||||
groupConfigs: [],
|
||||
});
|
||||
|
||||
const byKey = new Map(items.map((item) => [item.key, item]));
|
||||
// Root siblings: prod group + ungrouped host-root
|
||||
assert.equal(byKey.get("group:prod")?.posInSet, 1);
|
||||
assert.equal(byKey.get("group:prod")?.setSize, 2);
|
||||
assert.equal(byKey.get("host:host-root")?.posInSet, 2);
|
||||
assert.equal(byKey.get("host:host-root")?.setSize, 2);
|
||||
// Children of prod: east group + hosts A/B
|
||||
assert.equal(byKey.get("group:prod/east")?.posInSet, 1);
|
||||
assert.equal(byKey.get("group:prod/east")?.setSize, 3);
|
||||
assert.equal(byKey.get("host:host-a")?.setSize, 3);
|
||||
assert.equal(byKey.get("host:host-b")?.setSize, 3);
|
||||
// Child of prod/east: only host C
|
||||
assert.equal(byKey.get("host:host-c")?.posInSet, 1);
|
||||
assert.equal(byKey.get("host:host-c")?.setSize, 1);
|
||||
});
|
||||
1314
components/HostTreeView.tsx
Normal file
1314
components/HostTreeView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
55
components/KeyboardInteractiveModal.test.ts
Normal file
55
components/KeyboardInteractiveModal.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Created: 2026-07-15
|
||||
// Purpose: verify keyboard-interactive modal server prompt formatting.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
|
||||
|
||||
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "Keyboard-interactive authentication prompts from server",
|
||||
instructions: "为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Secondary Authentication Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
"Keyboard-interactive authentication prompts from server:",
|
||||
"| 为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
"| Secondary Authentication Password:",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "192.168.9.138",
|
||||
instructions: "",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, "");
|
||||
});
|
||||
|
||||
test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
|
||||
assert.match(modalSource, /onOpenChange=\{\(\) => \{\/\* intentionally non-dismissable \*\/\}\}/);
|
||||
assert.match(modalSource, /onInteractOutside=\{\(e\) => e\.preventDefault\(\)\}/);
|
||||
assert.match(modalSource, /onEscapeKeyDown=\{\(e\) => e\.preventDefault\(\)\}/);
|
||||
});
|
||||
317
components/KeyboardInteractiveModal.tsx
Normal file
317
components/KeyboardInteractiveModal.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Keyboard Interactive Authentication Modal
|
||||
* Global modal for handling SSH keyboard-interactive authentication (2FA/MFA)
|
||||
* This modal displays prompts from the SSH server and collects user responses.
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface KeyboardInteractivePrompt {
|
||||
prompt: string;
|
||||
echo: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardInteractiveRequest {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
hostId?: string;
|
||||
scope?: "terminal" | "external";
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
hostname?: string;
|
||||
savedPassword?: string | null;
|
||||
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
|
||||
allowSavePassword?: boolean;
|
||||
}
|
||||
|
||||
type KeyboardInteractiveServerPromptInput = Pick<
|
||||
KeyboardInteractiveRequest,
|
||||
"name" | "instructions" | "prompts" | "hostname"
|
||||
>;
|
||||
|
||||
/** Formats the server-supplied keyboard-interactive prompt block for display. */
|
||||
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
|
||||
const lines: string[] = [];
|
||||
const name = request.name?.trim();
|
||||
const hostname = request.hostname?.trim();
|
||||
const instructions = request.instructions?.trim();
|
||||
const hasServerText = !!instructions || !!(name && name !== hostname);
|
||||
if (!hasServerText) return "";
|
||||
if (name && name !== hostname) {
|
||||
lines.push(name.endsWith(":") ? name : `${name}:`);
|
||||
}
|
||||
if (instructions) {
|
||||
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
|
||||
lines.push(`| ${line}`);
|
||||
}
|
||||
}
|
||||
for (const prompt of request.prompts || []) {
|
||||
const promptText = prompt.prompt?.trim();
|
||||
if (promptText) {
|
||||
lines.push(`| ${promptText}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
|
||||
if (prompt.echo) return false;
|
||||
const lower = prompt.prompt.toLowerCase();
|
||||
if (!lower.includes("password") && !lower.includes("passwd")) return false;
|
||||
// Keep aligned with electron/bridges/sshAuthHelper.cjs OTP_PROMPT_PATTERN so
|
||||
// the modal never prefills the host login password into a second-factor field
|
||||
// (#2150). Backend also omits savedPassword for those challenges; this is
|
||||
// defense in depth if a caller still passes it.
|
||||
if (
|
||||
lower.includes("one-time") ||
|
||||
lower.includes("otp") ||
|
||||
lower.includes("verification") ||
|
||||
lower.includes("token") ||
|
||||
lower.includes("code") ||
|
||||
lower.includes("passcode") ||
|
||||
lower.includes("2fa") ||
|
||||
lower.includes("mfa") ||
|
||||
lower.includes("two-factor") ||
|
||||
lower.includes("two factor") ||
|
||||
lower.includes("multi-factor") ||
|
||||
lower.includes("multi factor") ||
|
||||
lower.includes("second factor") ||
|
||||
lower.includes("secondary password") ||
|
||||
lower.includes("secondary authentication") ||
|
||||
lower.includes("second password") ||
|
||||
lower.includes("additional password") ||
|
||||
lower.includes("re-enter password") ||
|
||||
lower.includes("reenter password") ||
|
||||
lower.includes("confirm password") ||
|
||||
lower.includes("edr") ||
|
||||
lower.includes("duo")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
|
||||
onCancel: (requestId: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [savePassword, setSavePassword] = useState(false);
|
||||
|
||||
// Index of the first password prompt (if any)
|
||||
const passwordPromptIndex = useMemo(() => {
|
||||
if (!request) return -1;
|
||||
return request.prompts.findIndex(p => isAPasswordPrompt(p));
|
||||
}, [request]);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
const initial = request.prompts.map(() => "");
|
||||
// Auto-fill saved password into the password prompt
|
||||
if (request.savedPassword && passwordPromptIndex >= 0) {
|
||||
initial[passwordPromptIndex] = request.savedPassword;
|
||||
}
|
||||
setResponses(initial);
|
||||
setShowPasswords(request.prompts.map(() => false));
|
||||
setIsSubmitting(false);
|
||||
setSavePassword(false);
|
||||
}
|
||||
}, [request, passwordPromptIndex]);
|
||||
|
||||
const handleResponseChange = useCallback((index: number, value: string) => {
|
||||
setResponses((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = value;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleShowPassword = useCallback((index: number) => {
|
||||
setShowPasswords((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = !updated[index];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const canSavePassword = request?.allowSavePassword !== false;
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!request || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
const passwordToSave =
|
||||
canSavePassword && savePassword && passwordPromptIndex >= 0
|
||||
? responses[passwordPromptIndex]
|
||||
: undefined;
|
||||
try {
|
||||
const submitted = await onSubmit(request.requestId, responses, passwordToSave);
|
||||
if (!submitted) setIsSubmitting(false);
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex, canSavePassword]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (!request || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const cancelled = await onCancel(request.requestId);
|
||||
if (!cancelled) setIsSubmitting(false);
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request, onCancel, isSubmitting]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
|
||||
const title = t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc");
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[425px]"
|
||||
hideCloseButton
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{serverPromptText && (
|
||||
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{serverPromptText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
{showPromptLabel && (
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
</Label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
type={isPassword && !showPassword ? "password" : "text"}
|
||||
value={responses[index] || ""}
|
||||
onChange={(e) => handleResponseChange(index, e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => toggleShowPassword(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Save password checkbox - first-factor password prompts only */}
|
||||
{canSavePassword && index === passwordPromptIndex && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={savePassword}
|
||||
onChange={(e) => setSavePassword(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("keyboard.interactive.savePassword")}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("keyboard.interactive.verifying")}
|
||||
</>
|
||||
) : (
|
||||
t("keyboard.interactive.submit")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyboardInteractiveModal;
|
||||
338
components/KeychainCardLayout.test.tsx
Normal file
338
components/KeychainCardLayout.test.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { Identity, SSHKey } from "../types.ts";
|
||||
import KeychainManager from "./KeychainManager.tsx";
|
||||
import { IdentityCard } from "./keychain/IdentityCard.tsx";
|
||||
import { KeyCard } from "./keychain/KeyCard.tsx";
|
||||
import {
|
||||
shouldShowIdentitySection,
|
||||
shouldShowKeySection,
|
||||
shouldShowSearchNoResults,
|
||||
} from "./keychain/utils.ts";
|
||||
|
||||
const longLabel =
|
||||
"sdakdjkasjakjskajskaijssdakdjkasjakjskajskaijssdakdjkasjakjskajskaijssdakdjkasjakjskajskaijs";
|
||||
|
||||
const renderWithI18n = (node: React.ReactElement) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(I18nProvider, { locale: "en" }, node),
|
||||
);
|
||||
|
||||
const installStorageStub = (viewMode: string | null = null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (viewMode) {
|
||||
values.set(STORAGE_KEY_VAULT_KEYS_VIEW_MODE, viewMode);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const installNavigatorStub = () => {
|
||||
const currentNavigator = globalThis.navigator;
|
||||
if (
|
||||
typeof currentNavigator?.platform === "string" &&
|
||||
typeof currentNavigator?.userAgent === "string"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Mac OS",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const identity: Identity = {
|
||||
id: "identity-1",
|
||||
label: longLabel,
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
password: "pw",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const keyItem: SSHKey = {
|
||||
id: "key-1",
|
||||
label: longLabel,
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
test("IdentityCard list layout constrains long labels", () => {
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(IdentityCard, {
|
||||
identity,
|
||||
viewMode: "list",
|
||||
isSelected: false,
|
||||
onClick: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /group cursor-pointer min-w-0 w-full max-w-full/);
|
||||
assert.doesNotMatch(markup, /group cursor-pointer min-w-0 w-full max-w-full overflow-hidden/);
|
||||
assert.match(markup, /flex items-center gap-3 h-full min-w-0/);
|
||||
assert.doesNotMatch(markup, /flex items-center gap-3 h-full min-w-0 overflow-hidden/);
|
||||
assert.match(markup, /min-w-0 flex-1 basis-0 overflow-hidden/);
|
||||
assert.match(markup, /block max-w-full truncate text-sm font-semibold/);
|
||||
});
|
||||
|
||||
test("KeyCard list layout constrains long labels", () => {
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(KeyCard, {
|
||||
keyItem,
|
||||
viewMode: "list",
|
||||
isSelected: false,
|
||||
isMac: false,
|
||||
onClick: () => {},
|
||||
onEdit: () => {},
|
||||
onExport: () => {},
|
||||
onCopyPublicKey: () => {},
|
||||
onDelete: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /group cursor-pointer min-w-0 w-full max-w-full/);
|
||||
assert.doesNotMatch(markup, /group cursor-pointer min-w-0 w-full max-w-full overflow-hidden/);
|
||||
assert.match(markup, /flex items-center gap-3 h-full min-w-0/);
|
||||
assert.doesNotMatch(markup, /flex items-center gap-3 h-full min-w-0 overflow-hidden/);
|
||||
assert.match(markup, /min-w-0 flex-1 basis-0 overflow-hidden/);
|
||||
assert.match(markup, /block max-w-full truncate text-sm font-semibold/);
|
||||
});
|
||||
|
||||
test("KeychainManager shows keys and identities on the same page", () => {
|
||||
installNavigatorStub();
|
||||
installStorageStub("list");
|
||||
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(KeychainManager, {
|
||||
keys: [keyItem],
|
||||
identities: [identity],
|
||||
hosts: [],
|
||||
proxyProfiles: [],
|
||||
customGroups: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [],
|
||||
onSave: () => {},
|
||||
onUpdate: () => {},
|
||||
onReorderKeys: () => {},
|
||||
onDelete: () => {},
|
||||
onSaveIdentity: () => {},
|
||||
onReorderIdentities: () => {},
|
||||
onDeleteIdentity: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /h-full min-w-0 w-full overflow-hidden flex relative/);
|
||||
assert.match(markup, /flex-1 min-w-0 w-full overflow-y-auto/);
|
||||
assert.doesNotMatch(markup, /flex-1 min-w-0 w-full overflow-y-auto overflow-x-hidden/);
|
||||
assert.match(markup, /flex min-w-0 w-full max-w-full flex-col gap-0/);
|
||||
assert.match(markup, /block min-w-0 w-full max-w-full/);
|
||||
assert.match(markup, /data-section="keychain-identities"/);
|
||||
assert.match(markup, /data-section="keychain-keys"/);
|
||||
assert.doesNotMatch(markup, /data-section="keychain-empty"/);
|
||||
assert.doesNotMatch(markup, />Identities<\/button>/);
|
||||
});
|
||||
|
||||
test("KeychainManager shows keys without the identities section when no identities exist", () => {
|
||||
installNavigatorStub();
|
||||
installStorageStub();
|
||||
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(KeychainManager, {
|
||||
keys: [keyItem],
|
||||
identities: [],
|
||||
onSave: () => {},
|
||||
onUpdate: () => {},
|
||||
onDelete: () => {},
|
||||
onSaveIdentity: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-section="keychain-keys"/);
|
||||
assert.doesNotMatch(markup, /data-section="keychain-identities"/);
|
||||
assert.doesNotMatch(markup, /data-section="keychain-empty"/);
|
||||
});
|
||||
|
||||
test("KeychainManager shows the empty prompt only when keys and identities are absent", () => {
|
||||
installNavigatorStub();
|
||||
installStorageStub();
|
||||
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(KeychainManager, {
|
||||
keys: [],
|
||||
identities: [],
|
||||
onSave: () => {},
|
||||
onUpdate: () => {},
|
||||
onDelete: () => {},
|
||||
onSaveIdentity: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-section="keychain-keys"/);
|
||||
assert.match(markup, /data-section="keychain-empty"/);
|
||||
assert.doesNotMatch(markup, /data-section="keychain-identities"/);
|
||||
});
|
||||
|
||||
test("KeychainManager hides the empty key CTA when identities exist without keys", () => {
|
||||
installNavigatorStub();
|
||||
installStorageStub();
|
||||
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(KeychainManager, {
|
||||
keys: [],
|
||||
identities: [identity],
|
||||
onSave: () => {},
|
||||
onUpdate: () => {},
|
||||
onDelete: () => {},
|
||||
onSaveIdentity: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-section="keychain-identities"/);
|
||||
assert.doesNotMatch(markup, /data-section="keychain-keys"/);
|
||||
assert.doesNotMatch(markup, /data-section="keychain-empty"/);
|
||||
});
|
||||
|
||||
test("KeychainManager exposes new-key, import-certificate, and new-identity header actions", () => {
|
||||
installNavigatorStub();
|
||||
installStorageStub();
|
||||
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(KeychainManager, {
|
||||
keys: [],
|
||||
identities: [],
|
||||
onSave: () => {},
|
||||
onUpdate: () => {},
|
||||
onDelete: () => {},
|
||||
onSaveIdentity: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
// Header split "New Key" uses the same secondary chrome as sibling actions
|
||||
assert.match(
|
||||
markup,
|
||||
/rounded-md shrink-0 bg-foreground\/5 text-foreground[\s\S]*?>New Key<\/button>/,
|
||||
);
|
||||
assert.match(markup, />Import Certificate<\/button>/);
|
||||
assert.match(markup, />New Identity<\/button>/);
|
||||
assert.doesNotMatch(markup, />KEY<\/button>/);
|
||||
assert.doesNotMatch(markup, />CERTIFICATE<\/button>/);
|
||||
assert.doesNotMatch(markup, />Identities<\/button>/);
|
||||
});
|
||||
|
||||
test("keychain search reveals matching keys when identities do not match", () => {
|
||||
assert.equal(shouldShowIdentitySection({
|
||||
identityCount: 1,
|
||||
filteredIdentityCount: 0,
|
||||
filteredKeyCount: 1,
|
||||
search: "matching-key",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("keychain search keeps identities for identity matches or no results", () => {
|
||||
assert.equal(shouldShowIdentitySection({
|
||||
identityCount: 1,
|
||||
filteredIdentityCount: 1,
|
||||
filteredKeyCount: 0,
|
||||
search: "matching-identity",
|
||||
}), true);
|
||||
assert.equal(shouldShowIdentitySection({
|
||||
identityCount: 1,
|
||||
filteredIdentityCount: 0,
|
||||
filteredKeyCount: 0,
|
||||
search: "no-results",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("keychain search shows both sections when both kinds match", () => {
|
||||
const state = {
|
||||
identityCount: 1,
|
||||
filteredIdentityCount: 1,
|
||||
filteredKeyCount: 1,
|
||||
search: "shared-label",
|
||||
};
|
||||
|
||||
assert.equal(shouldShowIdentitySection(state), true);
|
||||
assert.equal(shouldShowKeySection(state), true);
|
||||
});
|
||||
|
||||
test("keychain browsing shows keys and identities together", () => {
|
||||
assert.equal(shouldShowKeySection({
|
||||
identityCount: 1,
|
||||
filteredKeyCount: 1,
|
||||
search: "",
|
||||
}), true);
|
||||
assert.equal(shouldShowIdentitySection({
|
||||
identityCount: 1,
|
||||
filteredIdentityCount: 1,
|
||||
filteredKeyCount: 1,
|
||||
search: "",
|
||||
}), true);
|
||||
assert.equal(shouldShowIdentitySection({
|
||||
identityCount: 0,
|
||||
filteredIdentityCount: 0,
|
||||
filteredKeyCount: 1,
|
||||
search: "",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("keychain browsing hides keys when only identities exist", () => {
|
||||
assert.equal(shouldShowKeySection({
|
||||
identityCount: 1,
|
||||
filteredKeyCount: 0,
|
||||
search: "",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("keychain search hides the keys section when only identities match", () => {
|
||||
assert.equal(shouldShowKeySection({
|
||||
identityCount: 1,
|
||||
filteredKeyCount: 0,
|
||||
search: "identity-only",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("keychain search keeps keys visible for empty key searches without identities", () => {
|
||||
assert.equal(shouldShowKeySection({
|
||||
identityCount: 0,
|
||||
filteredKeyCount: 0,
|
||||
search: "no-results",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("keychain search hides keys when identities cover the empty-search state", () => {
|
||||
assert.equal(shouldShowKeySection({
|
||||
identityCount: 1,
|
||||
filteredKeyCount: 0,
|
||||
search: "no-results",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("keychain distinguishes search misses from an empty vault", () => {
|
||||
assert.equal(shouldShowSearchNoResults("missing", 0, 1), true);
|
||||
assert.equal(shouldShowSearchNoResults("", 0, 1), false);
|
||||
assert.equal(shouldShowSearchNoResults("missing", 0, 0), false);
|
||||
});
|
||||
185
components/KeychainEditPanel.tsx
Normal file
185
components/KeychainEditPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from "react";
|
||||
import { Eye, EyeOff, FileKey, Info } from "lucide-react";
|
||||
import type { SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type KeychainEditPanelProps = Record<string, any>;
|
||||
|
||||
export const KeychainEditPanel: React.FC<KeychainEditPanelProps> = ({
|
||||
panel,
|
||||
t,
|
||||
draftKey,
|
||||
setDraftKey,
|
||||
showPassphrase,
|
||||
setShowPassphrase,
|
||||
openKeyExport,
|
||||
onUpdate,
|
||||
closePanel,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.edit.labelRequired")}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, label: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.edit.keyLabelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reference key: show file path read-only */}
|
||||
{draftKey.source === 'reference' && draftKey.filePath && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.filePath")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs font-mono truncate cursor-default">
|
||||
{draftKey.filePath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{draftKey.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Managed key: show private key editor */}
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-destructive">
|
||||
{t("keychain.edit.privateKeyRequired")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, privateKey: e.target.value })
|
||||
}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.publicKey")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.publicKey || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, publicKey: e.target.value })
|
||||
}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.edit.certificate")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={draftKey.certificate || ""}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, certificate: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.edit.certificatePlaceholder")}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passphrase section */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, passphrase: e.target.value })
|
||||
}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editSavePassphrase"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, savePassphrase: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="editSavePassphrase" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Export section - only for managed keys */}
|
||||
{draftKey.source !== 'reference' && (
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-medium">
|
||||
{t("keychain.edit.keyExport")}
|
||||
</span>
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<Info size={10} className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={() => openKeyExport(panel.key)}
|
||||
>
|
||||
{t("keychain.edit.exportToHost")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
className="w-full h-11 mt-4"
|
||||
disabled={
|
||||
!draftKey.label?.trim() ||
|
||||
(draftKey.source !== 'reference' && !draftKey.privateKey?.trim())
|
||||
}
|
||||
onClick={() => {
|
||||
if (draftKey.id) {
|
||||
onUpdate({
|
||||
...panel.key,
|
||||
...(draftKey as SSHKey),
|
||||
});
|
||||
closePanel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.saveChanges")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
31
components/KeychainExportPanel.test.ts
Normal file
31
components/KeychainExportPanel.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./KeychainExportPanel.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('key export forwards the selected host SSH connection timeouts', () => {
|
||||
assert.match(source, /resolveHostSshConnectionTimeouts\(effectiveExportHost\)/);
|
||||
assert.match(source, /sshTcpConnectTimeoutMs: connectionTimeouts\.tcpConnectTimeoutSeconds \* 1000/);
|
||||
assert.match(source, /sshAuthReadyTimeoutMs: connectionTimeouts\.authReadyTimeoutSeconds \* 1000/);
|
||||
});
|
||||
|
||||
test('key export omits stale identity files for password-only auth', () => {
|
||||
assert.match(
|
||||
source,
|
||||
/fallbackIdentityFilePaths: exportAuth\.authMethod === "password" \|\| exportAuth\.keyId/,
|
||||
);
|
||||
});
|
||||
|
||||
test('key export passes the selected vault key to agent filtering', () => {
|
||||
assert.match(source, /resolveBridgeSshAgentAuth\(\s*effectiveExportHost,\s*exportAuth\.key,\s*exportAuth\.authMethod,\s*\)/);
|
||||
});
|
||||
|
||||
test('key export defaults blank usernames the same way as other SSH entry points', () => {
|
||||
assert.match(source, /username: exportAuth\.username \|\| "root"/);
|
||||
});
|
||||
|
||||
test('key export forwards host MFA metadata to one-off exec commands', () => {
|
||||
assert.match(source, /hostId: effectiveExportHost\.id/);
|
||||
assert.match(source, /requiresMfa: !!effectiveExportHost\.requiresMfa/);
|
||||
});
|
||||
322
components/KeychainExportPanel.tsx
Normal file
322
components/KeychainExportPanel.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React from "react";
|
||||
import { ChevronRight, Info } from "lucide-react";
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type KeychainExportPanelProps = Record<string, any>;
|
||||
|
||||
export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
panel,
|
||||
t,
|
||||
getKeyIcon,
|
||||
getKeyTypeDisplay,
|
||||
setShowHostSelector,
|
||||
exportHost,
|
||||
exportLocation,
|
||||
setExportLocation,
|
||||
exportFilename,
|
||||
setExportFilename,
|
||||
exportAdvancedOpen,
|
||||
setExportAdvancedOpen,
|
||||
exportScript,
|
||||
setExportScript,
|
||||
isExporting,
|
||||
setIsExporting,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
execCommand,
|
||||
onSaveIdentity,
|
||||
onSaveHost,
|
||||
closePanel,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Key info card */}
|
||||
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-md flex items-center justify-center",
|
||||
panel.key.certificate
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
{getKeyIcon(panel.key)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{panel.key.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("auth.keyType", { type: getKeyTypeDisplay(panel.key) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export to field */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.exportTo")}
|
||||
</Label>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-primary text-sm"
|
||||
onClick={() => setShowHostSelector(true)}
|
||||
>
|
||||
{t("keychain.export.selectHost")}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={exportHost?.label || ""}
|
||||
readOnly
|
||||
placeholder={t("common.selectAHostPlaceholder")}
|
||||
className="bg-muted/50 cursor-pointer"
|
||||
onClick={() => setShowHostSelector(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.location")}
|
||||
</Label>
|
||||
<Input
|
||||
value={exportLocation}
|
||||
onChange={(e) => setExportLocation(e.target.value)}
|
||||
placeholder=".ssh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filename field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.filename")}
|
||||
</Label>
|
||||
<Input
|
||||
value={exportFilename}
|
||||
onChange={(e) => setExportFilename(e.target.value)}
|
||||
placeholder="authorized_keys"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
|
||||
<Info
|
||||
size={14}
|
||||
className="mt-0.5 text-muted-foreground shrink-0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("keychain.export.note", {
|
||||
unix: "UNIX",
|
||||
advanced: t("common.advanced"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced collapsible */}
|
||||
<Collapsible
|
||||
open={exportAdvancedOpen}
|
||||
onOpenChange={setExportAdvancedOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current"
|
||||
>
|
||||
<span className="font-medium">{t("common.advanced")}</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={cn(
|
||||
"transition-transform",
|
||||
exportAdvancedOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.script")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={exportScript}
|
||||
onChange={(e) => setExportScript(e.target.value)}
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
placeholder={t("keychain.export.scriptPlaceholder")}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Export button */}
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
disabled={
|
||||
!exportHost ||
|
||||
!exportLocation ||
|
||||
!exportFilename ||
|
||||
isExporting
|
||||
}
|
||||
onClick={async () => {
|
||||
if (!exportHost || !panel.key.publicKey) return;
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
// Apply group defaults before resolving authentication
|
||||
// and connection settings for this one-off SSH command.
|
||||
const effectiveExportHost = exportHost.group
|
||||
? applyGroupDefaults(
|
||||
exportHost,
|
||||
resolveGroupDefaults(exportHost.group, groupConfigs),
|
||||
)
|
||||
: applyGroupDefaults(exportHost, {});
|
||||
const connectionTimeouts = resolveHostSshConnectionTimeouts(effectiveExportHost);
|
||||
const exportAuth = resolveHostAuth({
|
||||
host: effectiveExportHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const exportKeyAuth = resolveBridgeKeyAuth({
|
||||
key: exportAuth.key,
|
||||
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
|
||||
? undefined
|
||||
: effectiveExportHost.identityFilePaths,
|
||||
passphrase: exportAuth.passphrase,
|
||||
});
|
||||
const exportPassword = sanitizeCredentialValue(exportAuth.password);
|
||||
const exportAgentAuth = resolveBridgeSshAgentAuth(
|
||||
effectiveExportHost,
|
||||
exportAuth.key,
|
||||
exportAuth.authMethod,
|
||||
);
|
||||
|
||||
// Need either password or a usable key to run remote command.
|
||||
if (!hasBridgeSshCredentials({
|
||||
authMethod: exportAuth.authMethod,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
useSshAgent: exportAgentAuth.useSshAgent,
|
||||
})) {
|
||||
throw new Error(
|
||||
t("keychain.export.missingCredentials"),
|
||||
);
|
||||
}
|
||||
|
||||
// Escape the public key for shell (single quotes, escape existing quotes)
|
||||
const escapedPublicKey = panel.key.publicKey.replace(
|
||||
/'/g,
|
||||
"'\\''",
|
||||
);
|
||||
|
||||
// Build the command by replacing $1, $2, $3
|
||||
const scriptWithVars = exportScript
|
||||
.replace(/\$1/g, exportLocation)
|
||||
.replace(/\$2/g, exportFilename)
|
||||
.replace(/\$3/g, `'${escapedPublicKey}'`);
|
||||
|
||||
// Execute the script directly - SSH exec handles multiline commands
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: effectiveExportHost.hostname,
|
||||
hostId: effectiveExportHost.id,
|
||||
username: exportAuth.username || "root",
|
||||
port: effectiveExportHost.port || 22,
|
||||
authMethod: exportAuth.authMethod,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
certificate: exportAuth.key?.certificate,
|
||||
publicKey: exportAuth.key?.publicKey,
|
||||
keyId: exportAuth.keyId,
|
||||
keySource: exportAuth.key?.source,
|
||||
passphrase: exportKeyAuth.passphrase,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
...exportAgentAuth,
|
||||
// Carry the effective host's algorithm settings
|
||||
// (host value falling back to its group default)
|
||||
// so the one-off SSH exec honors them just like
|
||||
// the interactive terminal does.
|
||||
legacyAlgorithms: effectiveExportHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: effectiveExportHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: effectiveExportHost.algorithms,
|
||||
command,
|
||||
timeout: 30000,
|
||||
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
enableKeyboardInteractive: true,
|
||||
requiresMfa: !!effectiveExportHost.requiresMfa,
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
// Check result - code 0, null, or undefined with no stderr is success
|
||||
const exitCode = result?.code;
|
||||
const hasError = result?.stderr?.trim();
|
||||
if (exitCode === 0 || (exitCode == null && !hasError)) {
|
||||
// Update identity (preferred) or host to use this key for authentication
|
||||
if (exportHost.identityId && onSaveIdentity) {
|
||||
const existing = identities.find(
|
||||
(i) => i.id === exportHost.identityId,
|
||||
);
|
||||
if (existing) {
|
||||
onSaveIdentity({
|
||||
...existing,
|
||||
authMethod: "key",
|
||||
keyId: panel.key.id,
|
||||
});
|
||||
}
|
||||
} else if (onSaveHost) {
|
||||
onSaveHost({
|
||||
...exportHost,
|
||||
identityFileId: panel.key.id,
|
||||
authMethod: "key",
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("keychain.export.successMessage", {
|
||||
host: exportHost.label,
|
||||
}),
|
||||
t("keychain.export.successTitle"),
|
||||
);
|
||||
closePanel();
|
||||
} else {
|
||||
const errorMsg =
|
||||
hasError ||
|
||||
result?.stdout?.trim() ||
|
||||
t("keychain.export.exitCode", { code: exitCode });
|
||||
toast.error(
|
||||
t("keychain.export.failedMessage", { error: errorMsg }),
|
||||
t("keychain.export.failedTitle"),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
toast.error(
|
||||
t("keychain.export.failedPrefix", { error: message }),
|
||||
t("keychain.export.failedTitle"),
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExporting
|
||||
? t("keychain.export.exporting")
|
||||
: t("keychain.export.exportAndAttach")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
1057
components/KeychainManager.tsx
Normal file
1057
components/KeychainManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
751
components/KnownHostsManager.tsx
Normal file
751
components/KnownHostsManager.tsx
Normal file
@@ -0,0 +1,751 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
FolderOpen,
|
||||
Import,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
|
||||
import { fingerprintFromPublicKey } from "../domain/knownHosts";
|
||||
import {
|
||||
DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
shouldAutoScanSystemKnownHosts,
|
||||
} from "../domain/systemKnownHostsAutoImport";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, KnownHost } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "./ui/context-menu";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
hosts: Host[];
|
||||
onSave: (knownHost: KnownHost) => void;
|
||||
onUpdate: (knownHost: KnownHost) => void;
|
||||
onReorder: (knownHosts: KnownHost[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
onImportFromFile: (hosts: KnownHost[]) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
// Parse known_hosts file content - pure function, moved outside component
|
||||
const parseKnownHostsFile = (content: string): KnownHost[] => {
|
||||
const lines = content
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() && !line.startsWith("#"));
|
||||
const parsed: KnownHost[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const [hostPattern, keyType, publicKey] = parts;
|
||||
|
||||
let hostname = hostPattern;
|
||||
let port = 22;
|
||||
|
||||
const bracketMatch = hostPattern.match(/^\[([^\]]+)\]:(\d+)$/);
|
||||
if (bracketMatch) {
|
||||
hostname = bracketMatch[1];
|
||||
port = parseInt(bracketMatch[2], 10);
|
||||
} else if (hostPattern.includes(",")) {
|
||||
hostname = hostPattern.split(",")[0];
|
||||
}
|
||||
|
||||
if (hostname.startsWith("|1|")) {
|
||||
hostname = "(hashed)";
|
||||
}
|
||||
|
||||
const fullPublicKey = `${keyType} ${publicKey}`;
|
||||
// Compute the fingerprint up front so the SSH host verifier can match
|
||||
// against this record directly instead of re-deriving on every connect —
|
||||
// the re-derivation path is where the false "fingerprint changed"
|
||||
// warnings in #972 originated.
|
||||
const fingerprint = fingerprintFromPublicKey(fullPublicKey);
|
||||
|
||||
parsed.push({
|
||||
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
hostname,
|
||||
port,
|
||||
keyType,
|
||||
publicKey: fullPublicKey,
|
||||
fingerprint: fingerprint || undefined,
|
||||
discoveredAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
logger.warn("Failed to parse known_hosts line:", line);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
|
||||
// Well-known public service hostnames that should not be imported as managed hosts.
|
||||
const PUBLIC_SERVICE_HOSTNAMES = new Set([
|
||||
'github.com',
|
||||
'gitlab.com',
|
||||
'bitbucket.org',
|
||||
'ssh.dev.azure.com',
|
||||
'vs-ssh.visualstudio.com',
|
||||
]);
|
||||
|
||||
const isPublicServiceHost = (hostname: string): boolean =>
|
||||
PUBLIC_SERVICE_HOSTNAMES.has(hostname);
|
||||
// Memoized Grid Item Component
|
||||
|
||||
interface HostItemProps {
|
||||
knownHost: KnownHost;
|
||||
converted: boolean;
|
||||
viewMode: ViewMode;
|
||||
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onDelete: (id: string) => void;
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
}
|
||||
|
||||
const knownHostActionButtonClass =
|
||||
"h-8 w-8 shrink-0 opacity-0 transition-opacity group-hover:opacity-100";
|
||||
|
||||
const KnownHostItemActions: React.FC<{
|
||||
knownHost: KnownHost;
|
||||
converted: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
}> = ({ knownHost, converted, onDelete, onConvertToHost }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!converted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={knownHostActionButtonClass}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(knownHostActionButtonClass, "text-destructive hover:text-destructive hover:bg-destructive/10")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.remove")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HostItem = React.memo<HostItemProps>(
|
||||
({ knownHost, converted, viewMode, reorderProps, onDelete, onConvertToHost }) => {
|
||||
const { t } = useI18n();
|
||||
// Disabled to reduce log noise - uncomment for debugging
|
||||
// console.log('[HostItem] render:', knownHost.hostname);
|
||||
if (viewMode === "grid") {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer soft-card elevate rounded-xl h-[68px] px-3 py-2",
|
||||
converted && "opacity-60",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<VaultEntityIcon
|
||||
className={vaultPrimaryIconClass}
|
||||
icon={<Server size={18} />}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate block">
|
||||
{knownHost.hostname}
|
||||
</span>
|
||||
</div>
|
||||
<KnownHostItemActions
|
||||
knownHost={knownHost}
|
||||
converted={converted}
|
||||
onDelete={onDelete}
|
||||
onConvertToHost={onConvertToHost}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{!converted && (
|
||||
<ContextMenuItem onClick={() => onConvertToHost(knownHost)}>
|
||||
<ArrowRight className="mr-2 h-4 w-4" /> {t("action.convertToHost")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(knownHost.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("action.remove")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group flex items-center gap-3 px-3 py-2 h-14 rounded-lg hover:bg-secondary/60 transition-colors cursor-pointer",
|
||||
converted && "opacity-60",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
>
|
||||
<VaultEntityIcon
|
||||
className={vaultPrimaryIconClass}
|
||||
icon={<Server size={18} />}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate block">
|
||||
{knownHost.hostname}
|
||||
</span>
|
||||
</div>
|
||||
<KnownHostItemActions
|
||||
knownHost={knownHost}
|
||||
converted={converted}
|
||||
onDelete={onDelete}
|
||||
onConvertToHost={onConvertToHost}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{!converted && (
|
||||
<ContextMenuItem onClick={() => onConvertToHost(knownHost)}>
|
||||
<ArrowRight className="mr-2 h-4 w-4" /> {t("action.convertToHost")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(knownHost.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("action.remove")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HostItem.displayName = "HostItem";
|
||||
|
||||
const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
knownHosts,
|
||||
hosts,
|
||||
onSave: _onSave,
|
||||
onUpdate: _onUpdate,
|
||||
onReorder,
|
||||
onDelete,
|
||||
onConvertToHost,
|
||||
onImportFromFile,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readKnownHosts } = useKnownHostsBackend();
|
||||
const [search, setSearch] = useState("");
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const [autoImportSystemKnownHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS,
|
||||
);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const hasScannedRef = React.useRef(false);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
|
||||
// Define handleScanSystem before useEffect that depends on it
|
||||
const handleScanSystem = useCallback(async (silent = false) => {
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const content = await readKnownHosts();
|
||||
if (content === undefined) {
|
||||
if (!silent) toast.error(
|
||||
t("knownHosts.toast.scanUnavailable"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!content) {
|
||||
if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseKnownHostsFile(content);
|
||||
if (parsed.length === 0) {
|
||||
if (!silent) toast.info(
|
||||
t("knownHosts.toast.scanNoEntries"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingHostnames = new Set(
|
||||
knownHosts.map((h) => `${h.hostname}:${h.port}`),
|
||||
);
|
||||
const newHosts = parsed.filter(
|
||||
(h) => !existingHostnames.has(`${h.hostname}:${h.port}`) && !isPublicServiceHost(h.hostname),
|
||||
);
|
||||
const publicFiltered = parsed.filter((h) => isPublicServiceHost(h.hostname)).length;
|
||||
|
||||
if (newHosts.length > 0) {
|
||||
onImportFromFile(newHosts);
|
||||
if (!silent) toast.success(
|
||||
t("knownHosts.toast.scanImported", { count: newHosts.length }),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
} else {
|
||||
if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
|
||||
}
|
||||
|
||||
if (!silent && publicFiltered > 0) {
|
||||
toast.info(
|
||||
t("knownHosts.toast.scanFiltered", { count: publicFiltered }),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to scan system known_hosts:", err);
|
||||
if (!silent) toast.error(
|
||||
err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
} finally {
|
||||
onRefresh();
|
||||
setIsScanning(false);
|
||||
}
|
||||
}, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]);
|
||||
|
||||
// Auto-scan on first mount when enabled (silent — no toasts for missing known_hosts)
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldAutoScanSystemKnownHosts({
|
||||
autoImportEnabled: autoImportSystemKnownHosts,
|
||||
alreadyScanned: hasScannedRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasScannedRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
handleScanSystem(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoImportSystemKnownHosts, handleScanSystem]);
|
||||
|
||||
// Sort and filter hosts with deduplication by hostname
|
||||
const filteredHosts = useMemo(() => {
|
||||
// First, deduplicate by hostname (keep the most recent one)
|
||||
const hostnameMap = new Map<string, KnownHost>();
|
||||
for (const h of knownHosts) {
|
||||
const key = h.hostname;
|
||||
const existing = hostnameMap.get(key);
|
||||
if (!existing || h.discoveredAt > existing.discoveredAt) {
|
||||
hostnameMap.set(key, h);
|
||||
}
|
||||
}
|
||||
let result = Array.from(hostnameMap.values());
|
||||
|
||||
// Filter by search
|
||||
if (deferredSearch.trim()) {
|
||||
const term = deferredSearch.toLowerCase();
|
||||
result = result.filter(
|
||||
(h) =>
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
h.keyType.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case "az":
|
||||
return a.hostname.localeCompare(b.hostname);
|
||||
case "za":
|
||||
return b.hostname.localeCompare(a.hostname);
|
||||
case "newest":
|
||||
return b.discoveredAt - a.discoveredAt;
|
||||
case "oldest":
|
||||
return a.discoveredAt - b.discoveredAt;
|
||||
case "manual":
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return sortMode === "manual" ? sortByVaultOrder(result) : result;
|
||||
}, [knownHosts, deferredSearch, sortMode]);
|
||||
|
||||
// Limit rendered items for performance
|
||||
const displayedHosts = useMemo(() => {
|
||||
return filteredHosts.slice(0, RENDER_LIMIT);
|
||||
}, [filteredHosts]);
|
||||
|
||||
const hasMore = filteredHosts.length > RENDER_LIMIT;
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
const parsed = parseKnownHostsFile(content);
|
||||
|
||||
// Filter out already existing hosts and directly import
|
||||
const existingHostnames = new Set(
|
||||
knownHosts.map((h) => `${h.hostname}:${h.port}`),
|
||||
);
|
||||
const newHosts = parsed.filter(
|
||||
(h) => !existingHostnames.has(`${h.hostname}:${h.port}`) && !isPublicServiceHost(h.hostname),
|
||||
);
|
||||
|
||||
if (newHosts.length > 0) {
|
||||
onImportFromFile(newHosts);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[knownHosts, onImportFromFile],
|
||||
);
|
||||
|
||||
// Memoize host lookup for performance
|
||||
const hostIdSet = useMemo(() => new Set(hosts.map((h) => h.id)), [hosts]);
|
||||
|
||||
// Pre-compute converted status for all known hosts
|
||||
const convertedMap = useMemo(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
for (const kh of knownHosts) {
|
||||
if (kh.convertedToHostId) {
|
||||
map.set(kh.id, hostIdSet.has(kh.convertedToHostId));
|
||||
} else {
|
||||
map.set(kh.id, false);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [knownHosts, hostIdSet]);
|
||||
|
||||
// Memoized handlers to prevent re-renders
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteTarget = useMemo(
|
||||
() => knownHosts.find((knownHost) => knownHost.id === deleteTargetId) ?? null,
|
||||
[deleteTargetId, knownHosts],
|
||||
);
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
() => {
|
||||
if (!deleteTargetId) return;
|
||||
onDelete(deleteTargetId);
|
||||
setDeleteTargetId(null);
|
||||
},
|
||||
[deleteTargetId, onDelete],
|
||||
);
|
||||
|
||||
const handleConvertToHost = useCallback(
|
||||
(knownHost: KnownHost) => {
|
||||
onConvertToHost(knownHost);
|
||||
},
|
||||
[onConvertToHost],
|
||||
);
|
||||
|
||||
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
||||
|
||||
const knownHostReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode,
|
||||
dragType: "known-host-id",
|
||||
targetAttribute: "data-known-host-id",
|
||||
disabled: deferredSearch.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
|
||||
setSortMode("manual");
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize the rendered list to prevent re-renders
|
||||
const renderedItems = useMemo(() => {
|
||||
return displayedHosts.map((knownHost) => (
|
||||
<HostItem
|
||||
key={knownHost.id}
|
||||
knownHost={knownHost}
|
||||
converted={convertedMap.get(knownHost.id) || false}
|
||||
viewMode={viewMode}
|
||||
reorderProps={knownHostReorder.getItemReorderProps(knownHost.id, `known:${knownHost.id}`)}
|
||||
onDelete={handleDelete}
|
||||
onConvertToHost={handleConvertToHost}
|
||||
/>
|
||||
));
|
||||
}, [
|
||||
displayedHosts,
|
||||
convertedMap,
|
||||
viewMode,
|
||||
handleDelete,
|
||||
handleConvertToHost,
|
||||
knownHostReorder,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<VaultPageHeader>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<VaultHeaderSearch
|
||||
placeholder={t("knownHosts.search.placeholder")}
|
||||
className="flex-1 max-w-xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* View Mode Toggle */}
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
<ListIcon size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<ListIcon size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
{/* Sort Toggle */}
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
modes={["manual", "az", "za", "newest", "oldest"]}
|
||||
className={vaultHeaderIconButtonClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn("mr-2", isScanning && "animate-spin")}
|
||||
/>
|
||||
{t("knownHosts.action.scanSystem")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,known_hosts"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
<Import size={14} className="mr-2" />
|
||||
{t("knownHosts.action.importFile")}
|
||||
</Button>
|
||||
</div>
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
"p-4",
|
||||
viewMode === "grid"
|
||||
? "grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
||||
onDragOver={knownHostReorder.handleDragOver}
|
||||
onDropCapture={knownHostReorder.handleDropCapture}
|
||||
onDragEndCapture={knownHostReorder.handleDragEndCapture}
|
||||
>
|
||||
{displayedHosts.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-16 text-muted-foreground",
|
||||
viewMode === "grid" && "col-span-full",
|
||||
)}
|
||||
>
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Shield size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("knownHosts.empty.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm mb-4">
|
||||
{t("knownHosts.empty.desc")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn("mr-2", isScanning && "animate-spin")}
|
||||
/>
|
||||
{t("knownHosts.action.scanSystem")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openFilePicker}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("knownHosts.action.browseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{renderedItems}
|
||||
{hasMore && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-center py-4 text-sm text-muted-foreground",
|
||||
viewMode === "grid" && "col-span-full",
|
||||
)}
|
||||
>
|
||||
{t("knownHosts.results.showingLimited", {
|
||||
shown: displayedHosts.length,
|
||||
total: filteredHosts.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTargetId)}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
name: deleteTarget?.hostname ?? "",
|
||||
})}
|
||||
description={t("vault.deleteConfirm.desc")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTargetId(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom comparison - only compare data props, not callbacks
|
||||
const knownHostsManagerAreEqual = (
|
||||
prev: KnownHostsManagerProps,
|
||||
next: KnownHostsManagerProps,
|
||||
): boolean => {
|
||||
return prev.knownHosts === next.knownHosts && prev.hosts === next.hosts;
|
||||
};
|
||||
|
||||
export default memo(KnownHostsManager, knownHostsManagerAreEqual);
|
||||
15
components/LogView.accent.test.ts
Normal file
15
components/LogView.accent.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./LogView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('LogView applies live custom accent at the leaf from appearanceChromeStore', () => {
|
||||
assert.match(source, /useAppearanceChromeStore/);
|
||||
assert.match(source, /applyCustomAccentToTerminalTheme\(baseTheme, accentMode, customAccent\)/);
|
||||
// Must not rely on an accent-baked defaultTerminalTheme from AppShell bags.
|
||||
assert.match(
|
||||
source,
|
||||
/published defaultTerminalTheme is the stable base catalog/,
|
||||
);
|
||||
});
|
||||
349
components/LogView.tsx
Normal file
349
components/LogView.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { FileText, Download, Palette, X } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, TerminalTheme } from "../types";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import { applyCustomAccentToTerminalTheme } from "../domain/terminalAppearance";
|
||||
import { Button } from "./ui/button";
|
||||
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
|
||||
|
||||
interface LogViewProps {
|
||||
log: ConnectionLog;
|
||||
defaultTerminalTheme: TerminalTheme;
|
||||
defaultFontSize: number;
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
|
||||
}
|
||||
|
||||
const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
log,
|
||||
defaultTerminalTheme,
|
||||
defaultFontSize,
|
||||
isVisible,
|
||||
onClose,
|
||||
onUpdateLog,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [previewTheme, setPreviewTheme] = useState<TerminalTheme | null>(null);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
// Leaf accent: published defaultTerminalTheme is the stable base catalog
|
||||
// theme; apply live custom accent here so log replay matches terminal chrome
|
||||
// without thrashing AppShell on every HSL tick.
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const explicitThemeId = useMemo(() => {
|
||||
if (!log.themeId) return undefined;
|
||||
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|
||||
|| customThemes.some((theme) => theme.id === log.themeId);
|
||||
return exists ? log.themeId : undefined;
|
||||
}, [customThemes, log.themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (log.themeId && !explicitThemeId) {
|
||||
onUpdateLog(log.id, { themeId: undefined });
|
||||
}
|
||||
}, [explicitThemeId, log.id, log.themeId, onUpdateLog]);
|
||||
|
||||
// Use log's saved theme/fontSize or fall back to defaults
|
||||
const currentTheme = useMemo(() => {
|
||||
if (previewTheme) {
|
||||
return previewTheme;
|
||||
}
|
||||
const baseTheme = explicitThemeId
|
||||
? (TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
|| customThemes.find(t => t.id === explicitThemeId)
|
||||
|| defaultTerminalTheme)
|
||||
: defaultTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [accentMode, customAccent, customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = useMemo(() => {
|
||||
const date = new Date(log.startTime);
|
||||
return date.toLocaleString(resolvedLocale || undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}, [log.startTime, resolvedLocale]);
|
||||
|
||||
// Handle theme change
|
||||
const handleThemeChange = useCallback((themeId: string) => {
|
||||
onUpdateLog(log.id, { themeId });
|
||||
}, [log.id, onUpdateLog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeModalOpen) {
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
}, [themeModalOpen]);
|
||||
|
||||
// Handle font size change
|
||||
const handleFontSizeChange = useCallback((fontSize: number) => {
|
||||
onUpdateLog(log.id, { fontSize });
|
||||
}, [log.id, onUpdateLog]);
|
||||
|
||||
// Handle export
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!log.terminalData || isExporting) return;
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { netcattyBridge } = await import("../infrastructure/services/netcattyBridge");
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.exportSessionLog) {
|
||||
await bridge.exportSessionLog({
|
||||
terminalData: log.terminalData,
|
||||
hostLabel: log.hostLabel,
|
||||
hostname: log.hostname,
|
||||
startTime: log.startTime,
|
||||
format: 'txt',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export session log:', err);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [log.terminalData, log.hostLabel, log.hostname, log.startTime, isExporting]);
|
||||
|
||||
// Initialize terminal
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !isVisible) return;
|
||||
|
||||
// Create terminal
|
||||
const term = new XTerm({
|
||||
fontFamily: '"JetBrains Mono", "SF Mono", Monaco, Menlo, monospace',
|
||||
fontSize: currentFontSize,
|
||||
cursorBlink: false,
|
||||
cursorStyle: "underline",
|
||||
allowProposedApi: true,
|
||||
disableStdin: true, // Read-only mode
|
||||
theme: currentTheme.colors,
|
||||
scrollback: 10000,
|
||||
});
|
||||
|
||||
termRef.current = term;
|
||||
|
||||
// Create fit addon
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Open terminal
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Try to load WebGL addon for better performance
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
} catch {
|
||||
// WebGL not available, canvas renderer will be used
|
||||
}
|
||||
|
||||
// Fit terminal
|
||||
const initialFitTimer = setTimeout(() => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Write terminal data if available
|
||||
if (log.terminalData) {
|
||||
term.write(log.terminalData);
|
||||
} else {
|
||||
// No terminal data available
|
||||
term.writeln("\x1b[2m--- No terminal data captured for this session ---\x1b[0m");
|
||||
term.writeln("");
|
||||
term.writeln(`\x1b[36mHost:\x1b[0m ${log.hostname}`);
|
||||
term.writeln(`\x1b[36mUser:\x1b[0m ${log.username}`);
|
||||
term.writeln(`\x1b[36mProtocol:\x1b[0m ${log.protocol}`);
|
||||
term.writeln(`\x1b[36mTime:\x1b[0m ${formattedDate}`);
|
||||
if (log.endTime) {
|
||||
const duration = Math.round((log.endTime - log.startTime) / 1000);
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
term.writeln(`\x1b[36mDuration:\x1b[0m ${minutes}m ${seconds}s`);
|
||||
}
|
||||
}
|
||||
|
||||
setIsReady(true);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearTimeout(initialFitTimer);
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
setIsReady(false);
|
||||
};
|
||||
// Only re-create terminal when visibility or terminalData changes
|
||||
// Theme and font size updates are handled separately
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVisible, log.id, log.terminalData]);
|
||||
|
||||
// Update theme instantly without recreating terminal
|
||||
useEffect(() => {
|
||||
if (termRef.current && isReady) {
|
||||
termRef.current.options.theme = currentTheme.colors;
|
||||
}
|
||||
}, [currentTheme, isReady]);
|
||||
|
||||
// Update font size instantly without recreating terminal
|
||||
useEffect(() => {
|
||||
if (!termRef.current || !isReady) return;
|
||||
termRef.current.options.fontSize = currentFontSize;
|
||||
// Refit after font size change
|
||||
const refitTimer = setTimeout(() => {
|
||||
try {
|
||||
fitAddonRef.current?.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}, 10);
|
||||
return () => clearTimeout(refitTimer);
|
||||
}, [currentFontSize, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
if (!isVisible || !fitAddonRef.current) return;
|
||||
|
||||
const handleResize = () => {
|
||||
if (fitAddonRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
if (containerRef.current?.parentElement) {
|
||||
resizeObserver.observe(containerRef.current.parentElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex h-9 items-center justify-between gap-3 px-3 py-1 border-b border-border/50 bg-secondary/30 shrink-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-6 w-6 shrink-0 rounded-md flex items-center justify-center",
|
||||
isLocal
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
: "bg-blue-500/10 text-blue-500"
|
||||
)}
|
||||
>
|
||||
<FileText size={14} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<div className="min-w-0 text-sm font-medium leading-5 truncate">
|
||||
{isLocal ? t("logs.localTerminal") : log.hostname}
|
||||
</div>
|
||||
<div className="text-xs leading-4 truncate text-muted-foreground">
|
||||
{formattedDate} • {log.localUsername}@{log.localHostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-7 shrink-0 items-center gap-1.5">
|
||||
{/* Export button */}
|
||||
{log.terminalData && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="text-xs">{t("logView.export")}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Theme & font customization button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
>
|
||||
<Palette size={14} />
|
||||
<span className="text-xs">{t("logView.appearance")}</span>
|
||||
</Button>
|
||||
|
||||
<span className="h-6 inline-flex items-center rounded bg-secondary px-2 text-xs text-muted-foreground">
|
||||
{t("logView.readOnly")}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal container */}
|
||||
<div
|
||||
className="flex-1 overflow-hidden p-2"
|
||||
style={{ backgroundColor: currentTheme?.colors?.background || '#000000' }}
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
{/* Theme Customize Modal */}
|
||||
<ThemeCustomizeModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
currentThemeId={explicitThemeId}
|
||||
displayThemeId={currentTheme.id}
|
||||
currentFontSize={currentFontSize}
|
||||
onThemeChange={handleThemeChange}
|
||||
onThemeReset={() => onUpdateLog(log.id, { themeId: undefined })}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onPreviewThemeChange={setPreviewTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoization comparison
|
||||
const logViewAreEqual = (prev: LogViewProps, next: LogViewProps): boolean => {
|
||||
return (
|
||||
prev.log.id === next.log.id &&
|
||||
prev.log.themeId === next.log.themeId &&
|
||||
prev.log.fontSize === next.log.fontSize &&
|
||||
prev.isVisible === next.isVisible &&
|
||||
prev.defaultFontSize === next.defaultFontSize &&
|
||||
prev.defaultTerminalTheme.id === next.defaultTerminalTheme.id
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(LogViewComponent, logViewAreEqual);
|
||||
8
components/PassphraseModal.test.ts
Normal file
8
components/PassphraseModal.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { DEFAULT_REMEMBER_PASSPHRASE } from "./PassphraseModal.tsx";
|
||||
|
||||
test("passphrase remember checkbox defaults to unchecked for security", () => {
|
||||
assert.equal(DEFAULT_REMEMBER_PASSPHRASE, false);
|
||||
});
|
||||
188
components/PassphraseModal.tsx
Normal file
188
components/PassphraseModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Passphrase Modal
|
||||
* Modal for requesting passphrase for encrypted SSH keys
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface PassphraseRequest {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
sessionId?: string;
|
||||
bootEpoch?: number;
|
||||
}
|
||||
|
||||
/** Opt-in only: do not default to remembering key passphrases (#2024). */
|
||||
export const DEFAULT_REMEMBER_PASSPHRASE = false;
|
||||
|
||||
interface PassphraseModalProps {
|
||||
request: PassphraseRequest | null;
|
||||
onSubmit: (requestId: string, passphrase: string, remember: boolean) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onSkip?: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [rememberPassphrase, setRememberPassphrase] = useState(DEFAULT_REMEMBER_PASSPHRASE);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setPassphrase("");
|
||||
setShowPassphrase(false);
|
||||
setIsSubmitting(false);
|
||||
setRememberPassphrase(DEFAULT_REMEMBER_PASSPHRASE);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting || !passphrase) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, passphrase, rememberPassphrase);
|
||||
}, [request, passphrase, onSubmit, isSubmitting, rememberPassphrase]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (!request || !onSkip) return;
|
||||
onSkip(request.requestId);
|
||||
}, [request, onSkip]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting && passphrase) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting, passphrase]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[500px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle>{t("passphrase.title")}</DialogTitle>
|
||||
<DialogDescription className="mt-1 break-words">
|
||||
{request.hostname
|
||||
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
|
||||
: t("passphrase.desc", { keyName: keyDisplayName })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passphrase-input">
|
||||
{t("passphrase.label")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passphrase-input"
|
||||
type={showPassphrase ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
{t("passphrase.keyPath")}: <code className="text-xs break-all">{request.keyPath}</code>
|
||||
</p>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberPassphrase}
|
||||
onChange={(e) => setRememberPassphrase(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("passphrase.remember")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{onSkip && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("passphrase.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("passphrase.unlocking")}
|
||||
</>
|
||||
) : (
|
||||
t("passphrase.unlock")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PassphraseModal;
|
||||
85
components/PluginConnectionSection.tsx
Normal file
85
components/PluginConnectionSection.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import { Plug, RotateCcw } from "lucide-react";
|
||||
import type { Host, Identity, SSHKey } from "../types";
|
||||
import { usePluginConnectionSectionState } from "../application/state/usePluginConnectionSectionState";
|
||||
import { HostDetailsSection } from "./host-details";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
|
||||
type Props = {
|
||||
form: Host;
|
||||
setForm: React.Dispatch<React.SetStateAction<Host>>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onValidityChange: (valid: boolean) => void;
|
||||
identities: Identity[];
|
||||
keys: SSHKey[];
|
||||
};
|
||||
|
||||
export const PluginConnectionSection: React.FC<Props> = ({
|
||||
form,
|
||||
setForm,
|
||||
t,
|
||||
onValidityChange,
|
||||
identities,
|
||||
keys,
|
||||
}) => {
|
||||
const state = usePluginConnectionSectionState({
|
||||
form,
|
||||
setForm,
|
||||
t,
|
||||
onValidityChange,
|
||||
identities,
|
||||
keys,
|
||||
});
|
||||
|
||||
if (!state.active && state.providerCount === 0) return null;
|
||||
|
||||
return (
|
||||
<HostDetailsSection
|
||||
icon={<Plug size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.plugin.title")}
|
||||
hint={state.active && !state.installed ? t("hostDetails.plugin.unavailable") : undefined}
|
||||
>
|
||||
<Combobox
|
||||
options={state.providerOptions}
|
||||
value={state.providerId}
|
||||
onValueChange={state.selectProvider}
|
||||
placeholder={t("hostDetails.plugin.provider.placeholder")}
|
||||
emptyText={t("hostDetails.plugin.provider.empty")}
|
||||
/>
|
||||
{state.active ? (
|
||||
<>
|
||||
<Combobox
|
||||
options={state.authenticationOptions}
|
||||
value={form.pluginConnection?.authenticationProviderId ?? ""}
|
||||
onValueChange={state.selectAuthenticationProvider}
|
||||
placeholder={t("hostDetails.plugin.authentication.placeholder")}
|
||||
/>
|
||||
<Combobox
|
||||
options={state.credentialOptions}
|
||||
value={form.pluginConnection?.credentialId ?? ""}
|
||||
onValueChange={state.selectCredential}
|
||||
placeholder={t("hostDetails.plugin.credential.placeholder")}
|
||||
/>
|
||||
<textarea
|
||||
value={state.configurationText}
|
||||
onChange={(event) => state.updateConfiguration(event.target.value)}
|
||||
spellCheck={false}
|
||||
className="min-h-32 w-full resize-y rounded-md border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t("hostDetails.plugin.configuration.label")}
|
||||
/>
|
||||
{state.configurationError ? <p className="text-xs text-destructive">{state.configurationError}</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={state.resetToSsh}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-2" />
|
||||
{t("hostDetails.plugin.useSsh")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</HostDetailsSection>
|
||||
);
|
||||
};
|
||||
24
components/PortForwardingNew.test.ts
Normal file
24
components/PortForwardingNew.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./PortForwardingNew.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("port forwarding toolbar exposes start-all and stop-all actions", () => {
|
||||
assert.match(source, /pf\.action\.startAll/);
|
||||
assert.match(source, /pf\.action\.stopAll/);
|
||||
assert.match(source, /handleStartAll/);
|
||||
assert.match(source, /handleStopAll/);
|
||||
assert.match(source, /startAllTunnels/);
|
||||
assert.match(source, /stopAllTunnels/);
|
||||
assert.match(source, /startableRules\.length === 0/);
|
||||
assert.match(source, /stoppableRules\.length === 0/);
|
||||
assert.match(source, /<Play size=\{14\} \/>/);
|
||||
assert.match(source, /<Square size=\{14\} \/>/);
|
||||
});
|
||||
|
||||
test("port forwarding bulk actions stay on start/stop all without selection checkboxes", () => {
|
||||
assert.match(source, /startAllTunnels\(/);
|
||||
assert.match(source, /stopAllTunnels\(/);
|
||||
assert.doesNotMatch(source, /selectedRuleIds|bulkDelete|type="checkbox"/);
|
||||
});
|
||||
1128
components/PortForwardingNew.tsx
Normal file
1128
components/PortForwardingNew.tsx
Normal file
File diff suppressed because it is too large
Load Diff
74
components/ProtocolSelectDialog.test.tsx
Normal file
74
components/ProtocolSelectDialog.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import type { Host } from "../types";
|
||||
import ProtocolSelectDialog from "./ProtocolSelectDialog";
|
||||
import { PROTOCOL_VISUAL_STYLES } from "./protocolVisuals";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "prod-db",
|
||||
hostname: "10.2.0.31",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
moshEnabled: true,
|
||||
etEnabled: true,
|
||||
telnetEnabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("protocol select dialog restores host label and shared protocol visuals", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<ProtocolSelectDialog
|
||||
host={host()}
|
||||
onSelect={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Connect to prod-db/);
|
||||
assert.match(markup, /10\.2\.0\.31:22/);
|
||||
assert.match(markup, /lucide-plug/);
|
||||
assert.match(markup, new RegExp(PROTOCOL_VISUAL_STYLES.ssh.selected.replace("/", "\\/")));
|
||||
assert.match(markup, new RegExp(PROTOCOL_VISUAL_STYLES.mosh.idle.replace("/", "\\/")));
|
||||
assert.match(markup, new RegExp(PROTOCOL_VISUAL_STYLES.et.idle.replace("/", "\\/")));
|
||||
assert.match(markup, new RegExp(PROTOCOL_VISUAL_STYLES.telnet.idle.replace("/", "\\/")));
|
||||
assert.match(markup, /lucide-shield/);
|
||||
assert.match(markup, /lucide-radio/);
|
||||
assert.match(markup, /lucide-link-2/);
|
||||
assert.match(markup, /lucide-terminal/);
|
||||
});
|
||||
|
||||
test("plugin protocol hosts default to the plugin provider when optional built-in protocols exist", () => {
|
||||
const providerId = "com.example.transport.connection";
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<ProtocolSelectDialog
|
||||
host={host({
|
||||
protocol: `plugin:${providerId}`,
|
||||
pluginConnection: {
|
||||
providerId,
|
||||
configuration: { endpoint: "opaque-target" },
|
||||
},
|
||||
protocols: [{ protocol: "telnet", port: 2323, enabled: true }],
|
||||
telnetEnabled: true,
|
||||
})}
|
||||
onSelect={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.ok(markup.indexOf(providerId) < markup.indexOf(">Telnet<"));
|
||||
assert.match(markup, new RegExp(`border-primary bg-primary/5[^>]*>[\\s\\S]*${providerId}`));
|
||||
const providerStart = markup.lastIndexOf("<button", markup.indexOf(providerId));
|
||||
const providerEnd = markup.indexOf("</button>", markup.indexOf(providerId));
|
||||
assert.doesNotMatch(markup.slice(providerStart, providerEnd), /type="number"/);
|
||||
assert.equal((markup.match(/type="number"/g) ?? []).length, 3);
|
||||
assert.doesNotMatch(markup, /10\.2\.0\.31:22/);
|
||||
});
|
||||
229
components/ProtocolSelectDialog.tsx
Normal file
229
components/ProtocolSelectDialog.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Plug } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, HostProtocol } from "../types";
|
||||
import { getProtocolVisualStyle, type ProtocolVisualKey } from "./protocolVisuals";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
interface ProtocolOption {
|
||||
protocol: HostProtocol;
|
||||
port: number;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ProtocolSelectDialogProps {
|
||||
host: Host;
|
||||
onSelect: (protocol: HostProtocol, port: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ProtocolSelectDialog: React.FC<ProtocolSelectDialogProps> = ({
|
||||
host,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const protocolOptions = useMemo<ProtocolOption[]>(() => {
|
||||
const options: ProtocolOption[] = [];
|
||||
const pluginOption = isPluginHostProtocol(host.protocol) && host.pluginConnection
|
||||
? {
|
||||
protocol: host.protocol,
|
||||
port: host.port || 22,
|
||||
label: host.pluginConnection.providerId,
|
||||
description: host.pluginConnection.providerId,
|
||||
enabled: true,
|
||||
} satisfies ProtocolOption
|
||||
: null;
|
||||
|
||||
if (pluginOption) options.push(pluginOption);
|
||||
|
||||
const sshEnabled =
|
||||
host.protocol === "ssh" ||
|
||||
!host.protocol ||
|
||||
host.protocols?.some((p) => p.protocol === "ssh" && p.enabled);
|
||||
if (sshEnabled !== false) {
|
||||
const sshConfig = host.protocols?.find((p) => p.protocol === "ssh");
|
||||
options.push({
|
||||
protocol: "ssh",
|
||||
port: sshConfig?.port || host.port || 22,
|
||||
label: "SSH",
|
||||
description: `ssh ${host.hostname}`,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (host.moshEnabled || host.protocols?.some((p) => p.protocol === "mosh" && p.enabled)) {
|
||||
const moshConfig = host.protocols?.find((p) => p.protocol === "mosh");
|
||||
options.push({
|
||||
protocol: "mosh",
|
||||
port: moshConfig?.port || host.port || 22,
|
||||
label: "Mosh",
|
||||
description: `mosh ${host.hostname}`,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (host.etEnabled || host.protocols?.some((p) => p.protocol === "et" && p.enabled)) {
|
||||
options.push({
|
||||
protocol: "et",
|
||||
port: host.port || 22,
|
||||
label: "EternalTerminal",
|
||||
description: `et ${host.hostname}`,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
host.telnetEnabled ||
|
||||
host.protocol === "telnet" ||
|
||||
host.protocols?.some((p) => p.protocol === "telnet" && p.enabled)
|
||||
) {
|
||||
const telnetConfig = host.protocols?.find((p) => p.protocol === "telnet");
|
||||
options.push({
|
||||
protocol: "telnet",
|
||||
port: telnetConfig?.port || host.telnetPort || 23,
|
||||
label: "Telnet",
|
||||
description: `telnet ${host.hostname}`,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [host]);
|
||||
|
||||
const [ports, setPorts] = useState<Record<HostProtocol, number>>(() => {
|
||||
const initial: Record<string, number> = {};
|
||||
protocolOptions.forEach((opt) => {
|
||||
initial[opt.protocol] = opt.port;
|
||||
});
|
||||
return initial as Record<HostProtocol, number>;
|
||||
});
|
||||
|
||||
const [selectedProtocol, setSelectedProtocol] = useState<HostProtocol>(
|
||||
protocolOptions[0]?.protocol || "ssh",
|
||||
);
|
||||
|
||||
const handlePortChange = (protocol: HostProtocol, value: string) => {
|
||||
const port = parseInt(value, 10);
|
||||
if (!isNaN(port) && port > 0 && port <= 65535) {
|
||||
setPorts((prev) => ({ ...prev, [protocol]: port }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
onSelect(selectedProtocol, ports[selectedProtocol] || 22);
|
||||
};
|
||||
|
||||
const hostEndpoint = isPluginHostProtocol(selectedProtocol)
|
||||
? host.hostname
|
||||
: formatHostPort(
|
||||
host.hostname,
|
||||
ports[selectedProtocol] || host.port || 22,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onCancel}>
|
||||
<div
|
||||
className="w-[560px] max-w-[90vw] bg-background border border-border rounded-2xl animate-in fade-in-0 zoom-in-95 duration-200"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 12px 24px -8px rgba(0, 0, 0, 0.15)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Plug size={18} className="shrink-0 text-foreground" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold truncate">
|
||||
{t("quickConnect.connectTitle", {
|
||||
host: host.label?.trim() || host.hostname,
|
||||
})}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{hostEndpoint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("protocolSelect.chooseProtocol")}</h3>
|
||||
<div className="space-y-3">
|
||||
{protocolOptions.map((option) => {
|
||||
const visual = isPluginHostProtocol(option.protocol)
|
||||
? {
|
||||
icon: <Plug size={18} />,
|
||||
selected: "bg-primary text-primary-foreground",
|
||||
idle: "bg-secondary text-muted-foreground",
|
||||
}
|
||||
: getProtocolVisualStyle(option.protocol as ProtocolVisualKey);
|
||||
return (
|
||||
<button
|
||||
key={option.protocol}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all text-left",
|
||||
selectedProtocol === option.protocol
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/60 hover:border-border hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => setSelectedProtocol(option.protocol)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-lg flex items-center justify-center",
|
||||
selectedProtocol === option.protocol ? visual.selected : visual.idle,
|
||||
)}
|
||||
>
|
||||
{visual.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isPluginHostProtocol(option.protocol) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("protocolSelect.port")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={ports[option.protocol] || option.port}
|
||||
onChange={(e) => handlePortChange(option.protocol, e.target.value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedProtocol(option.protocol);
|
||||
}}
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button onClick={handleContinue}>{t("common.continue")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtocolSelectDialog;
|
||||
228
components/ProxyPanel.test.tsx
Normal file
228
components/ProxyPanel.test.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Identity, ProxyProfile } from "../types.ts";
|
||||
import { ProxyPanel } from "./host-details/ProxyPanel.tsx";
|
||||
|
||||
const proxyProfile: ProxyProfile = {
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "socks5",
|
||||
host: "office-proxy.example.com",
|
||||
port: 1080,
|
||||
},
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const proxyIdentity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(ProxyPanel, {
|
||||
proxyConfig: undefined,
|
||||
proxyProfiles: [],
|
||||
identities: [],
|
||||
selectedProxyProfileId: undefined,
|
||||
onUpdateProxy: () => {},
|
||||
onSelectProxyProfile: () => {},
|
||||
onClearProxy: () => {},
|
||||
onBack: () => {},
|
||||
onCancel: () => {},
|
||||
layout: "inline",
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("ProxyPanel shows saved proxy selection when reusable profiles exist", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [proxyProfile],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
});
|
||||
|
||||
assert.match(markup, /Saved proxy/);
|
||||
assert.match(markup, /office-proxy\.example\.com:1080/);
|
||||
assert.doesNotMatch(markup, /Proxy host/);
|
||||
});
|
||||
|
||||
test("ProxyPanel labels saved ProxyCommand profiles without showing command contents", () => {
|
||||
const commandProxy: ProxyProfile = {
|
||||
id: "proxy-command-1",
|
||||
label: "Cloudflare Access",
|
||||
config: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
createdAt: 1,
|
||||
};
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [commandProxy],
|
||||
selectedProxyProfileId: commandProxy.id,
|
||||
});
|
||||
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("ProxyPanel keeps manual proxy fields available without a saved profile selection", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [proxyProfile],
|
||||
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 3128 },
|
||||
});
|
||||
|
||||
assert.match(markup, /Saved proxy/);
|
||||
assert.match(markup, /Proxy host/);
|
||||
assert.match(markup, /manual-proxy\.example\.com/);
|
||||
});
|
||||
|
||||
test("ProxyPanel shows a clear missing state for stale saved proxy selections", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [proxyProfile],
|
||||
selectedProxyProfileId: "missing-proxy",
|
||||
});
|
||||
|
||||
assert.match(markup, /Missing saved proxy/);
|
||||
assert.match(markup, /Proxy host/);
|
||||
});
|
||||
|
||||
test("ProxyPanel disables saving invalid manual proxy ports", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 65536 },
|
||||
});
|
||||
|
||||
assert.match(markup, /Port must be between 1 and 65535/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel shows a clear missing state for stale proxy identities", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "manual-proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Missing keychain identity/);
|
||||
assert.match(markup, /Username/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has a stale identity", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Missing keychain identity/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has an incomplete identity", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: undefined }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity needs a username and password/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has an unreadable identity password", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity password cannot be read/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel supports custom ProxyCommand settings", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h",
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /Command/);
|
||||
assert.match(markup, /cloudflared access ssh --hostname %h/);
|
||||
assert.match(markup, /Use %h for the target host/);
|
||||
assert.doesNotMatch(markup, /Proxy host/);
|
||||
assert.doesNotMatch(markup, /Credentials/);
|
||||
});
|
||||
|
||||
test("ProxyPanel uses a dropdown for proxy type selection", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 3128 },
|
||||
});
|
||||
|
||||
assert.match(markup, /role="combobox"/);
|
||||
assert.match(markup, /aria-label="Type"/);
|
||||
});
|
||||
|
||||
test("ProxyPanel offers keychain identities for HTTP and SOCKS5 proxy credentials", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "manual-proxy.example.com",
|
||||
port: 1080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Keychain identity/);
|
||||
assert.match(markup, /Proxy login/);
|
||||
assert.match(markup, /proxy-user/);
|
||||
});
|
||||
225
components/ProxyProfilesManager.test.tsx
Normal file
225
components/ProxyProfilesManager.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { isValidProxyPort } from "../domain/proxyProfiles.ts";
|
||||
import { STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { Identity, ProxyProfile } from "../types.ts";
|
||||
import { prepareProxyProfileForSave, ProxyProfilesManager } from "./ProxyProfilesManager.tsx";
|
||||
|
||||
const proxyProfile: ProxyProfile = {
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8080,
|
||||
},
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const proxyIdentity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const installStorageStub = (viewMode: string | null = null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (viewMode) {
|
||||
values.set(STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, viewMode);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderManager = (
|
||||
viewMode: string | null = null,
|
||||
profiles: ProxyProfile[] = [proxyProfile],
|
||||
) => {
|
||||
installStorageStub(viewMode);
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(ProxyProfilesManager, {
|
||||
proxyProfiles: profiles,
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
identities: [proxyIdentity],
|
||||
onUpdateProxyProfiles: () => {},
|
||||
onUpdateHosts: () => {},
|
||||
onUpdateGroupConfigs: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
test("ProxyProfilesManager uses the shared Vault grid card style by default", () => {
|
||||
const markup = renderManager();
|
||||
|
||||
assert.match(markup, /Add Proxy/);
|
||||
assert.match(markup, /aria-label="Search proxies…"/);
|
||||
assert.match(markup, /aria-label="Office Proxy, HTTP, 127\.0\.0\.1:8080, 0 linked"/);
|
||||
assert.match(markup, /Office Proxy/);
|
||||
assert.match(markup, /127\.0\.0\.1:8080/);
|
||||
});
|
||||
|
||||
test("ProxyProfilesManager uses the shared Vault list row style when persisted", () => {
|
||||
const markup = renderManager("list");
|
||||
|
||||
assert.match(markup, /aria-label="Office Proxy, HTTP, 127\.0\.0\.1:8080, 0 linked"/);
|
||||
assert.match(markup, /Office Proxy/);
|
||||
assert.match(markup, /127\.0\.0\.1:8080/);
|
||||
});
|
||||
|
||||
test("ProxyProfilesManager validates proxy ports", () => {
|
||||
assert.equal(isValidProxyPort(1), true);
|
||||
assert.equal(isValidProxyPort(65535), true);
|
||||
assert.equal(isValidProxyPort(0), false);
|
||||
assert.equal(isValidProxyPort(65536), false);
|
||||
assert.equal(isValidProxyPort(10.5), false);
|
||||
});
|
||||
|
||||
test("ProxyProfilesManager hides ProxyCommand contents in profile summaries", () => {
|
||||
const markup = renderManager(null, [
|
||||
{
|
||||
id: "proxy-command-1",
|
||||
label: "Cloudflare Access",
|
||||
config: {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
},
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(markup, /aria-label="Cloudflare Access, ProxyCommand, ProxyCommand, 0 linked"/);
|
||||
assert.match(markup, /Cloudflare Access/);
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("prepareProxyProfileForSave saves identity auth without stale manual credentials", () => {
|
||||
const result = prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
label: " Office Proxy ",
|
||||
config: {
|
||||
type: "http",
|
||||
host: " proxy.example.com ",
|
||||
port: 3128,
|
||||
identityId: proxyIdentity.id,
|
||||
username: "stale-user",
|
||||
password: "stale-secret",
|
||||
},
|
||||
},
|
||||
[proxyIdentity],
|
||||
2,
|
||||
);
|
||||
|
||||
assert.deepEqual(result.saved?.config, {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: proxyIdentity.id,
|
||||
});
|
||||
assert.equal(result.saved?.label, "Office Proxy");
|
||||
assert.equal(result.saved?.updatedAt, 2);
|
||||
});
|
||||
|
||||
test("prepareProxyProfileForSave rejects missing and incomplete proxy identities", () => {
|
||||
assert.equal(
|
||||
prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
},
|
||||
[proxyIdentity],
|
||||
).error,
|
||||
"missingIdentity",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
},
|
||||
[{ ...proxyIdentity, password: undefined }],
|
||||
).error,
|
||||
"incompleteIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyProfileForSave rejects unreadable proxy identity passwords", () => {
|
||||
assert.equal(
|
||||
prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
},
|
||||
[{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
|
||||
).error,
|
||||
"unreadableIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyProfileForSave strips stale ProxyCommand data from HTTP/SOCKS profiles", () => {
|
||||
const result = prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
assert.deepEqual(result.saved?.config, {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
});
|
||||
});
|
||||
736
components/ProxyProfilesManager.tsx
Normal file
736
components/ProxyProfilesManager.tsx
Normal file
@@ -0,0 +1,736 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
Route,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
hasIncompleteProxyIdentity,
|
||||
hasMissingProxyIdentity,
|
||||
hasUnreadableProxyCredential,
|
||||
isProxyCommandConfig,
|
||||
isValidProxyPort,
|
||||
normalizeManualProxyConfig,
|
||||
removeProxyProfileReferences,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { GroupConfig, Host, Identity, ProxyConfig, ProxyProfile } from "../types";
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card } from "./ui/card";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "./ui/context-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { toast } from "./ui/toast";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import {
|
||||
VaultEntityIcon,
|
||||
vaultProxyCommandIconClass,
|
||||
vaultProxyHttpIconClass,
|
||||
vaultProxySocksIconClass,
|
||||
} from "./vault/VaultEntityIcon";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
interface ProxyProfilesManagerProps {
|
||||
proxyProfiles: ProxyProfile[];
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
identities: Identity[];
|
||||
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
}
|
||||
|
||||
export type ProxyProfileSaveError =
|
||||
| "required"
|
||||
| "port"
|
||||
| "missingIdentity"
|
||||
| "incompleteIdentity"
|
||||
| "unreadableIdentity";
|
||||
|
||||
export const prepareProxyProfileForSave = (
|
||||
draft: ProxyProfile,
|
||||
identities: Identity[],
|
||||
updatedAt = Date.now(),
|
||||
): { saved?: ProxyProfile; error?: ProxyProfileSaveError } => {
|
||||
const label = draft.label.trim();
|
||||
const host = draft.config.host.trim();
|
||||
const command = draft.config.command?.trim() || "";
|
||||
const isCommand = isProxyCommandConfig(draft.config);
|
||||
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
|
||||
return { error: "required" };
|
||||
}
|
||||
if (!isCommand && !isValidProxyPort(draft.config.port)) {
|
||||
return { error: "port" };
|
||||
}
|
||||
if (!isCommand && hasMissingProxyIdentity(draft.config, identities)) {
|
||||
return { error: "missingIdentity" };
|
||||
}
|
||||
if (!isCommand && hasIncompleteProxyIdentity(draft.config, identities)) {
|
||||
return { error: "incompleteIdentity" };
|
||||
}
|
||||
if (!isCommand && hasUnreadableProxyCredential(draft.config, identities)) {
|
||||
return { error: "unreadableIdentity" };
|
||||
}
|
||||
|
||||
const normalizedConfig = normalizeManualProxyConfig(draft.config);
|
||||
if (!normalizedConfig) return { error: "required" };
|
||||
|
||||
return {
|
||||
saved: {
|
||||
...draft,
|
||||
label,
|
||||
config: normalizedConfig,
|
||||
updatedAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createDraftProfile = (): ProxyProfile => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
label: "",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
};
|
||||
|
||||
const getProfileUsageCount = (
|
||||
profileId: string,
|
||||
hosts: Host[],
|
||||
groupConfigs: GroupConfig[],
|
||||
): number =>
|
||||
hosts.filter((host) => host.proxyProfileId === profileId).length +
|
||||
groupConfigs.filter((config) => config.proxyProfileId === profileId).length;
|
||||
|
||||
type ProxyProfilesViewMode = "grid" | "list";
|
||||
|
||||
const proxyProtocolMeta = {
|
||||
http: {
|
||||
label: "HTTP",
|
||||
Icon: Globe,
|
||||
iconClassName: vaultProxyHttpIconClass,
|
||||
},
|
||||
socks5: {
|
||||
label: "SOCKS5",
|
||||
Icon: Route,
|
||||
iconClassName: vaultProxySocksIconClass,
|
||||
},
|
||||
command: {
|
||||
labelKey: "hostDetails.proxyPanel.command",
|
||||
Icon: SquareTerminal,
|
||||
iconClassName: vaultProxyCommandIconClass,
|
||||
},
|
||||
} satisfies Record<ProxyConfig["type"], {
|
||||
label?: string;
|
||||
labelKey?: string;
|
||||
Icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
iconClassName: string;
|
||||
}>;
|
||||
|
||||
interface ProxyProfileCardProps {
|
||||
profile: ProxyProfile;
|
||||
usageCount: number;
|
||||
viewMode: ProxyProfilesViewMode;
|
||||
isSelected: boolean;
|
||||
reorderProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
profile,
|
||||
usageCount,
|
||||
viewMode,
|
||||
isSelected,
|
||||
reorderProps,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
|
||||
const protocol = proxyProtocolMeta[profile.config.type];
|
||||
const protocolLabel = protocol.labelKey ? t(protocol.labelKey) : protocol.label;
|
||||
const ProtocolIcon = protocol.Icon;
|
||||
const endpoint = formatProxyConfigEndpoint(profile.config);
|
||||
const accessibleLabel = `${profile.label}, ${protocolLabel}, ${endpoint}, ${usageLabel}`;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
{...reorderProps}
|
||||
type="button"
|
||||
aria-label={accessibleLabel}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group w-full text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<VaultEntityIcon
|
||||
className={protocol.iconClassName}
|
||||
title={protocolLabel}
|
||||
icon={<ProtocolIcon size={18} />}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
{endpoint} -{" "}
|
||||
{protocolLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Pencil size={14} className="mr-2" />
|
||||
{t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onDuplicate}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
{t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
proxyProfiles,
|
||||
hosts,
|
||||
groupConfigs,
|
||||
identities,
|
||||
onUpdateProxyProfiles,
|
||||
onUpdateHosts,
|
||||
onUpdateGroupConfigs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const proxyProfilesViewMode: ProxyProfilesViewMode =
|
||||
viewMode === "list" ? "list" : "grid";
|
||||
const [draft, setDraft] = useState<ProxyProfile | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const manualCredentialsValue = "__manual_credentials__";
|
||||
const missingIdentityValue = "__missing_identity__";
|
||||
const selectedDraftIdentity = useMemo(
|
||||
() => identities.find((identity) => identity.id === draft?.config.identityId),
|
||||
[draft?.config.identityId, identities],
|
||||
);
|
||||
const hasMissingDraftIdentity = hasMissingProxyIdentity(draft?.config, identities);
|
||||
const hasIncompleteDraftIdentity = hasIncompleteProxyIdentity(draft?.config, identities);
|
||||
const hasUnreadableDraftIdentity = hasUnreadableProxyCredential(draft?.config, identities);
|
||||
const selectedDraftIdentityValue =
|
||||
selectedDraftIdentity?.id ||
|
||||
(hasMissingDraftIdentity ? missingIdentityValue : manualCredentialsValue);
|
||||
|
||||
const usageByProfileId = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const profile of proxyProfiles) {
|
||||
map.set(profile.id, getProfileUsageCount(profile.id, hosts, groupConfigs));
|
||||
}
|
||||
return map;
|
||||
}, [groupConfigs, hosts, proxyProfiles]);
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const result = !q ? proxyProfiles : proxyProfiles.filter((profile) =>
|
||||
profile.label.toLowerCase().includes(q) ||
|
||||
profile.config.host.toLowerCase().includes(q) ||
|
||||
(profile.config.command || "").toLowerCase().includes(q) ||
|
||||
profile.config.type.toLowerCase().includes(q),
|
||||
);
|
||||
return sortByVaultOrder(result);
|
||||
}, [proxyProfiles, search]);
|
||||
|
||||
const profileReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode: proxyProfilesViewMode,
|
||||
dragType: "proxy-profile-id",
|
||||
targetAttribute: "data-proxy-profile-id",
|
||||
disabled: search.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onUpdateProxyProfiles(reorderVaultItems(proxyProfiles, sourceId, targetId, position));
|
||||
},
|
||||
});
|
||||
|
||||
const updateDraftConfig = (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: updateProxyConfigField(prev.config, field, value),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setDraft(createDraftProfile());
|
||||
};
|
||||
|
||||
const openEdit = (profile: ProxyProfile) => {
|
||||
setDraft({
|
||||
...profile,
|
||||
config: { ...profile.config },
|
||||
});
|
||||
};
|
||||
|
||||
const duplicateProfile = (profile: ProxyProfile) => {
|
||||
const now = Date.now();
|
||||
onUpdateProxyProfiles([
|
||||
...proxyProfiles,
|
||||
{
|
||||
...profile,
|
||||
id: crypto.randomUUID(),
|
||||
label: t("proxyProfiles.copyName", { name: profile.label }),
|
||||
config: { ...profile.config },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const saveDraft = () => {
|
||||
if (!draft) return;
|
||||
const result = prepareProxyProfileForSave(draft, identities);
|
||||
if (!result.saved) {
|
||||
const messageKey = result.error === "port"
|
||||
? "proxyProfiles.error.port"
|
||||
: result.error === "missingIdentity"
|
||||
? "hostDetails.proxyPanel.missingIdentity"
|
||||
: result.error === "incompleteIdentity"
|
||||
? "hostDetails.proxyPanel.incompleteIdentity"
|
||||
: result.error === "unreadableIdentity"
|
||||
? "hostDetails.proxyPanel.unreadableIdentity"
|
||||
: "proxyProfiles.error.required";
|
||||
toast.error(t(messageKey));
|
||||
return;
|
||||
}
|
||||
const saved = result.saved;
|
||||
|
||||
onUpdateProxyProfiles(
|
||||
proxyProfiles.some((profile) => profile.id === saved.id)
|
||||
? proxyProfiles.map((profile) => profile.id === saved.id ? saved : profile)
|
||||
: [...proxyProfiles, saved],
|
||||
);
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
|
||||
hosts,
|
||||
groupConfigs,
|
||||
});
|
||||
onUpdateProxyProfiles(proxyProfiles.filter((profile) => profile.id !== deleteTarget.id));
|
||||
onUpdateHosts(cleaned.hosts);
|
||||
onUpdateGroupConfigs(cleaned.groupConfigs);
|
||||
if (draft?.id === deleteTarget.id) {
|
||||
setDraft(null);
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex relative">
|
||||
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
|
||||
<VaultPageHeader>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("proxyProfiles.action.add")}
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
|
||||
<VaultHeaderSearch
|
||||
aria-label={t("proxyProfiles.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("proxyProfiles.search.placeholder")}
|
||||
className="flex-shrink w-64"
|
||||
/>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("proxyProfiles.viewMode")}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(vaultHeaderIconButtonClass, "flex-shrink-0")}
|
||||
>
|
||||
{proxyProfilesViewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
<ListIcon size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={proxyProfilesViewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={proxyProfilesViewMode === "list" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<ListIcon size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</VaultPageHeader>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onDragOverCapture={profileReorder.handleDragOverCapture}
|
||||
onDragOver={profileReorder.handleDragOver}
|
||||
onDropCapture={profileReorder.handleDropCapture}
|
||||
onDragEndCapture={profileReorder.handleDragEndCapture}
|
||||
>
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={vaultSectionTitleClass}>
|
||||
{t("proxyProfiles.section.proxies")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("proxyProfiles.count.items", { count: filteredProfiles.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{filteredProfiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Globe size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("proxyProfiles.empty.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm mb-4">
|
||||
{t("proxyProfiles.empty.desc")}
|
||||
</p>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={14} className="mr-2" />
|
||||
{t("proxyProfiles.action.add")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
proxyProfilesViewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0"
|
||||
}
|
||||
>
|
||||
{filteredProfiles.map((profile) => (
|
||||
<ProxyProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
usageCount={usageByProfileId.get(profile.id) ?? 0}
|
||||
viewMode={proxyProfilesViewMode}
|
||||
isSelected={draft?.id === profile.id}
|
||||
reorderProps={profileReorder.getItemReorderProps(profile.id, `proxy:${profile.id}`)}
|
||||
onClick={() => openEdit(profile)}
|
||||
onEdit={() => openEdit(profile)}
|
||||
onDuplicate={() => duplicateProfile(profile)}
|
||||
onDelete={() => setDeleteTarget(profile)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft && (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={() => setDraft(null)}
|
||||
title={draft.label || t("proxyProfiles.panel.newTitle")}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("proxyProfiles.field.name")}</p>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t("proxyProfiles.field.name")}
|
||||
value={draft.label}
|
||||
onChange={(event) => setDraft({ ...draft, label: event.target.value })}
|
||||
placeholder={t("proxyProfiles.field.name")}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("field.type")}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.config.type}
|
||||
onValueChange={(value) => updateDraftConfig("type", value as ProxyConfig["type"])}
|
||||
>
|
||||
<SelectTrigger aria-label={t("field.type")} className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="socks5">SOCKS5</SelectItem>
|
||||
<SelectItem value="command">{t("hostDetails.proxyPanel.command")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isProxyCommandConfig(draft.config) ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.proxyPanel.commandHelp")}
|
||||
</p>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.commandPlaceholder")}
|
||||
value={draft.config.command || ""}
|
||||
onChange={(event) => updateDraftConfig("command", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.commandPlaceholder")}
|
||||
className="h-10 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
value={draft.config.host}
|
||||
onChange={(event) => updateDraftConfig("host", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.port")}
|
||||
type="number"
|
||||
value={draft.config.port || ""}
|
||||
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
className="h-10 w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!isProxyCommandConfig(draft.config) && <Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxyPanel.credentials")}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
|
||||
</div>
|
||||
{identities.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.proxyPanel.keychainIdentity")}
|
||||
</p>
|
||||
<Select
|
||||
value={selectedDraftIdentityValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === missingIdentityValue) return;
|
||||
updateDraftConfig(
|
||||
"identityId",
|
||||
value === manualCredentialsValue ? undefined : value,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("hostDetails.proxyPanel.keychainIdentity")}
|
||||
className="h-10"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={manualCredentialsValue}>
|
||||
{t("hostDetails.proxyPanel.manualCredentials")}
|
||||
</SelectItem>
|
||||
{hasMissingDraftIdentity && (
|
||||
<SelectItem value={missingIdentityValue}>
|
||||
{t("hostDetails.proxyPanel.missingIdentity")}
|
||||
</SelectItem>
|
||||
)}
|
||||
{identities.map((identity) => (
|
||||
<SelectItem key={identity.id} value={identity.id}>
|
||||
{identity.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{hasMissingDraftIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t("hostDetails.proxyPanel.missingIdentity")}
|
||||
</div>
|
||||
)}
|
||||
{hasIncompleteDraftIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t("hostDetails.proxyPanel.incompleteIdentity")}
|
||||
</div>
|
||||
)}
|
||||
{hasUnreadableDraftIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t("hostDetails.proxyPanel.unreadableIdentity")}
|
||||
</div>
|
||||
)}
|
||||
{selectedDraftIdentity ? (
|
||||
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t("hostDetails.proxyPanel.keychainIdentity")}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{selectedDraftIdentity.label} - {selectedDraftIdentity.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
||||
value={draft.config.username || ""}
|
||||
onChange={(event) => updateDraftConfig("username", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
type="password"
|
||||
value={draft.config.password || ""}
|
||||
onChange={(event) => updateDraftConfig("password", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<Button className="w-full" onClick={saveDraft}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
)}
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-destructive" />
|
||||
{t("proxyProfiles.delete.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget
|
||||
? t("proxyProfiles.delete.desc", {
|
||||
name: deleteTarget.label,
|
||||
count: usageByProfileId.get(deleteTarget.id) ?? 0,
|
||||
})
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t("action.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxyProfilesManager;
|
||||
49
components/QuickAddSnippetDialog.test.tsx
Normal file
49
components/QuickAddSnippetDialog.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { getQuickAddSnippetInitialCommand } from "./QuickAddSnippetDialog.tsx";
|
||||
|
||||
const source = readFileSync(new URL("./QuickAddSnippetDialog.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("quick add snippet event can prefill command", () => {
|
||||
const event = {
|
||||
detail: { command: "ls -la\npwd" },
|
||||
} as CustomEvent<{ command?: string }>;
|
||||
|
||||
assert.equal(getQuickAddSnippetInitialCommand(event), "ls -la\npwd");
|
||||
});
|
||||
|
||||
test("quick add snippet event defaults to an empty command", () => {
|
||||
assert.equal(getQuickAddSnippetInitialCommand({} as Event), "");
|
||||
assert.equal(
|
||||
getQuickAddSnippetInitialCommand({
|
||||
detail: { command: 123 },
|
||||
} as unknown as Event),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("quick add snippet form binds shortkeys and uses a centered Dialog modal", () => {
|
||||
assert.match(source, /DialogContent/);
|
||||
assert.match(source, /from '\.\/ui\/dialog'/);
|
||||
assert.doesNotMatch(source, /AsidePanel/);
|
||||
assert.match(source, /snippets\.field\.shortkey/);
|
||||
assert.match(source, /findActiveSystemShortcutConflict/);
|
||||
assert.match(source, /keyEventToString/);
|
||||
assert.match(source, /shortkey: shortkey \|\| undefined/);
|
||||
assert.match(source, /if \(e\.defaultPrevented\) return/);
|
||||
// Escape while recording a shortkey cancels capture instead of closing
|
||||
assert.match(source, /onEscapeKeyDown/);
|
||||
assert.match(source, /isRecordingShortkey/);
|
||||
// Pointer dismissals must close even while recording (no stale-recording gate)
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/if\s*\(\s*isRecordingShortkey\s*\)\s*\{\s*setIsRecordingShortkey\(false\);\s*setShortkeyError\(null\);\s*return;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("shared Dialog exposes data-dialog-close for Cmd+W / hasOpenAppDialog", () => {
|
||||
const dialogSource = readFileSync(new URL("./ui/dialog.tsx", import.meta.url), "utf8");
|
||||
assert.match(dialogSource, /data-dialog-close="true"/);
|
||||
});
|
||||
469
components/QuickAddSnippetDialog.tsx
Normal file
469
components/QuickAddSnippetDialog.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* QuickAddSnippetDialog — lightweight "new / edit snippet" modal mounted at the
|
||||
* App root and triggered by the `netcatty:snippets:add` / `:edit` window events.
|
||||
*
|
||||
* Opens as a centered Dialog so it does not compete with the scripts side panel.
|
||||
* Fields: label, command, package, shortkey, multi-line mode.
|
||||
* Advanced fields (target hosts, tags) can still be set later in the full
|
||||
* Snippets manager.
|
||||
*/
|
||||
|
||||
import { Keyboard, Package, RotateCcw } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { Snippet } from '../domain/models';
|
||||
import { findActiveSystemShortcutConflict } from '../domain/activeKeyBindings';
|
||||
import {
|
||||
type HotkeyScheme,
|
||||
type KeyBinding,
|
||||
keyEventToString,
|
||||
keyStringToKeyboardEvent,
|
||||
matchesKeyBinding,
|
||||
} from '../domain/models';
|
||||
import { isScriptSnippet } from '../domain/snippetScript.ts';
|
||||
import { cn, isMacPlatform } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
|
||||
|
||||
export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onCreateSnippet: (snippet: Snippet) => void;
|
||||
onUpdateSnippet?: (snippet: Snippet) => void;
|
||||
onCreatePackage?: (packagePath: string) => void;
|
||||
/** Optional — used to validate shortkey conflicts with system bindings. */
|
||||
hotkeyScheme?: HotkeyScheme;
|
||||
keyBindings?: KeyBinding[];
|
||||
}
|
||||
|
||||
export function getQuickAddSnippetInitialCommand(event: Event): string {
|
||||
const detail = (event as CustomEvent<{ command?: unknown }>).detail;
|
||||
return typeof detail?.command === 'string' ? detail.command : '';
|
||||
}
|
||||
|
||||
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onCreateSnippet,
|
||||
onUpdateSnippet,
|
||||
onCreatePackage,
|
||||
hotkeyScheme = 'disabled',
|
||||
keyBindings = [],
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const [noAutoRun, setNoAutoRun] = useState(false);
|
||||
const [multiLineRunMode, setMultiLineRunMode] = useState<Snippet['multiLineRunMode']>(undefined);
|
||||
const [shortkey, setShortkey] = useState<string | undefined>(undefined);
|
||||
const [isRecordingShortkey, setIsRecordingShortkey] = useState(false);
|
||||
const [shortkeyError, setShortkeyError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Snippet | null>(null);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Listen for the global "add snippet" request dispatched by the
|
||||
// terminal-side ScriptsSidePanel + button. We reset form state on
|
||||
// every open so stale input from a previous cancel does not leak.
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
setEditing(null);
|
||||
setLabel('');
|
||||
setCommand(getQuickAddSnippetInitialCommand(event));
|
||||
setPackagePath('');
|
||||
setNoAutoRun(false);
|
||||
setMultiLineRunMode(undefined);
|
||||
setShortkey(undefined);
|
||||
setShortkeyError(null);
|
||||
setIsRecordingShortkey(false);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:add', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:add', handler);
|
||||
}, []);
|
||||
|
||||
// Sibling event for editing an existing snippet from the ScriptsSidePanel
|
||||
// context menu. Prefills the form and flips the dialog into update mode.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ snippet?: Snippet }>).detail;
|
||||
const snippet = detail?.snippet;
|
||||
if (!snippet || isScriptSnippet(snippet)) return;
|
||||
setEditing(snippet);
|
||||
setLabel(snippet.label ?? '');
|
||||
setCommand(snippet.command ?? '');
|
||||
setPackagePath(snippet.package ?? '');
|
||||
setNoAutoRun(snippet.noAutoRun ?? false);
|
||||
setMultiLineRunMode(snippet.multiLineRunMode);
|
||||
setShortkey(snippet.shortkey);
|
||||
setShortkeyError(null);
|
||||
setIsRecordingShortkey(false);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:edit', handler);
|
||||
}, []);
|
||||
|
||||
// Focus the label field when the modal opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const focusTimer = window.setTimeout(() => labelInputRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [open]);
|
||||
|
||||
const isMac = useMemo(() => (
|
||||
hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform())
|
||||
), [hotkeyScheme]);
|
||||
|
||||
const existingShortkeys = useMemo(() => (
|
||||
snippets.filter((s) => Boolean(s.shortkey) && s.id !== editing?.id)
|
||||
), [snippets, editing?.id]);
|
||||
|
||||
const normalizeKeyString = useCallback((value: string) => (
|
||||
value.toLowerCase().replace(/\s+/g, '')
|
||||
), []);
|
||||
|
||||
const validateShortkey = useCallback((key: string): string | null => {
|
||||
if (!key) return null;
|
||||
|
||||
const systemConflict = findActiveSystemShortcutConflict(key, hotkeyScheme, keyBindings);
|
||||
if (systemConflict) {
|
||||
const nameKey = `settings.shortcuts.binding.${systemConflict.id}`;
|
||||
const name = t(nameKey) !== nameKey ? t(nameKey) : systemConflict.label;
|
||||
return t('snippets.shortkey.error.systemConflict', { name });
|
||||
}
|
||||
|
||||
const syntheticEvent = keyStringToKeyboardEvent(key);
|
||||
if (syntheticEvent) {
|
||||
for (const snippet of existingShortkeys) {
|
||||
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: snippet.label });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const normalizedKey = normalizeKeyString(key);
|
||||
const conflictingSnippet = existingShortkeys.find((snippet) => (
|
||||
snippet.shortkey && normalizeKeyString(snippet.shortkey) === normalizedKey
|
||||
));
|
||||
if (conflictingSnippet) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
existingShortkeys,
|
||||
hotkeyScheme,
|
||||
isMac,
|
||||
keyBindings,
|
||||
normalizeKeyString,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Shortkey recording capture. Escape cancels recording only (does not close the modal).
|
||||
useEffect(() => {
|
||||
if (!isRecordingShortkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
|
||||
|
||||
const keyString = keyEventToString(e, isMac);
|
||||
const error = validateShortkey(keyString);
|
||||
if (error) {
|
||||
setShortkeyError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setShortkeyError(null);
|
||||
setShortkey(keyString);
|
||||
setIsRecordingShortkey(false);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
window.addEventListener('click', handleClick, true);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [isRecordingShortkey, isMac, validateShortkey]);
|
||||
|
||||
// Derive combobox options from the union of existing packages (from
|
||||
// props) and any package path referenced by an existing snippet, so
|
||||
// the user can reuse anything they see in the main snippets view.
|
||||
const packageOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const p of packages) {
|
||||
if (p) set.add(p);
|
||||
}
|
||||
for (const s of snippets) {
|
||||
if (s.package) set.add(s.package);
|
||||
}
|
||||
return Array.from(set).sort().map((value) => ({ value, label: value }));
|
||||
}, [packages, snippets]);
|
||||
|
||||
const canSave = label.trim().length > 0 && command.trim().length > 0;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
// Escape while recording is gated by onEscapeKeyDown; X/backdrop always close.
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
}, [handleClose]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSave) return;
|
||||
const trimmedPackage = packagePath.trim();
|
||||
// If the user typed a brand new package name, surface it to the parent
|
||||
// so it can be added to the user's package list alongside the snippet.
|
||||
if (trimmedPackage && !packages.includes(trimmedPackage)) {
|
||||
onCreatePackage?.(trimmedPackage);
|
||||
}
|
||||
if (editing && onUpdateSnippet) {
|
||||
// Preserve tags/targets/etc. that this lightweight panel does not expose.
|
||||
onUpdateSnippet({
|
||||
...editing,
|
||||
label: label.trim(),
|
||||
command,
|
||||
package: trimmedPackage || '',
|
||||
noAutoRun: noAutoRun || undefined,
|
||||
multiLineRunMode,
|
||||
shortkey: shortkey || undefined,
|
||||
});
|
||||
} else {
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
noAutoRun: noAutoRun || undefined,
|
||||
multiLineRunMode,
|
||||
shortkey: shortkey || undefined,
|
||||
});
|
||||
}
|
||||
handleClose();
|
||||
}, [
|
||||
canSave,
|
||||
packagePath,
|
||||
packages,
|
||||
onCreatePackage,
|
||||
onCreateSnippet,
|
||||
onUpdateSnippet,
|
||||
editing,
|
||||
label,
|
||||
command,
|
||||
noAutoRun,
|
||||
multiLineRunMode,
|
||||
shortkey,
|
||||
handleClose,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
// Cmd/Ctrl+Enter from anywhere in the modal saves the snippet.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[canSave, handleSave],
|
||||
);
|
||||
|
||||
const handleSubmitShortcut = useCallback(() => {
|
||||
if (canSave) handleSave();
|
||||
}, [canSave, handleSave]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-2xl w-[min(42rem,calc(100vw-2rem))] max-h-[min(90vh,720px)] flex flex-col gap-0 p-0 overflow-hidden"
|
||||
onKeyDown={handleKeyDown}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isRecordingShortkey) {
|
||||
e.preventDefault();
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="px-6 pt-6 pb-3 shrink-0">
|
||||
<DialogTitle>
|
||||
{t(editing ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.empty.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-2 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-label" className="text-xs">
|
||||
{t('snippets.field.description')}
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-add-snippet-label"
|
||||
ref={labelInputRef}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('snippets.field.descriptionPlaceholder')}
|
||||
className="h-9"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SnippetScriptEditor
|
||||
id="quick-add-snippet-command"
|
||||
label={t('snippets.field.scriptRequired')}
|
||||
value={command}
|
||||
onChange={setCommand}
|
||||
onSubmitShortcut={handleSubmitShortcut}
|
||||
placeholder="echo hello"
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs flex items-center gap-1.5">
|
||||
<Package size={12} /> {t('snippets.field.package')}
|
||||
</Label>
|
||||
<Combobox
|
||||
value={packagePath}
|
||||
onValueChange={setPackagePath}
|
||||
options={packageOptions}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate
|
||||
onCreateNew={setPackagePath}
|
||||
createText={t('snippets.field.createPackage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noAutoRun}
|
||||
onChange={(e) => setNoAutoRun(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{t('snippets.field.multiLineRunMode')}</Label>
|
||||
<Select
|
||||
value={multiLineRunMode ?? 'paste'}
|
||||
onValueChange={(value) => setMultiLineRunMode(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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{t('snippets.field.shortkey')}</Label>
|
||||
{shortkey ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setShortkey(undefined);
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('snippets.shortkey.clear')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isRecordingShortkey}
|
||||
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')
|
||||
: shortkey || t('snippets.shortkey.placeholder')}
|
||||
</button>
|
||||
{shortkeyError ? (
|
||||
<p className="text-xs text-destructive">{shortkeyError}</p>
|
||||
) : null}
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t border-border/60 shrink-0 sm:justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddSnippetDialog;
|
||||
105
components/QuickConnectWizard.test.tsx
Normal file
105
components/QuickConnectWizard.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { PROTOCOL_VISUAL_STYLES } from "./protocolVisuals";
|
||||
|
||||
test("QuickConnectWizard offers ET without obsolete Mosh path or log controls", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickConnectWizard
|
||||
open
|
||||
target={{ hostname: "example.com" }}
|
||||
keys={[]}
|
||||
identities={[]}
|
||||
onConnect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Eternal Terminal/);
|
||||
assert.doesNotMatch(markup, /mosh --server/);
|
||||
assert.doesNotMatch(markup, /Show logs|Hide logs/);
|
||||
});
|
||||
|
||||
test("quick connect shows saved identities as credential presets", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickConnectWizard
|
||||
open
|
||||
target={{ hostname: "192.0.2.10" }}
|
||||
keys={[]}
|
||||
identities={[{
|
||||
id: "identity-root",
|
||||
label: "Root devices",
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
created: 1,
|
||||
}]}
|
||||
onConnect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Credential preset/);
|
||||
assert.match(markup, /Choose a saved identity/);
|
||||
});
|
||||
|
||||
test("quick connect protocol step uses simplified connect header and protocol icons", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickConnectWizard
|
||||
open
|
||||
target={{ hostname: "10.2.0.31" }}
|
||||
keys={[]}
|
||||
identities={[]}
|
||||
onConnect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Connect to 10\.2\.0\.31:22/);
|
||||
assert.match(markup, /lucide-plug/);
|
||||
assert.doesNotMatch(markup, />_</);
|
||||
assert.match(markup, new RegExp(PROTOCOL_VISUAL_STYLES.ssh.selected.replace("/", "\\/")));
|
||||
assert.match(markup, new RegExp(PROTOCOL_VISUAL_STYLES.mosh.idle.replace("/", "\\/")));
|
||||
assert.match(markup, new RegExp(PROTOCOL_VISUAL_STYLES.et.idle.replace("/", "\\/")));
|
||||
assert.match(markup, new RegExp(PROTOCOL_VISUAL_STYLES.telnet.idle.replace("/", "\\/")));
|
||||
assert.match(markup, /lucide-shield/);
|
||||
assert.match(markup, /lucide-radio/);
|
||||
assert.match(markup, /lucide-link-2/);
|
||||
assert.match(markup, /lucide-terminal/);
|
||||
assert.match(markup, /text-sm font-medium[^>]*>Choose protocol</);
|
||||
});
|
||||
|
||||
test("quick connect credential section title matches protocol section title style", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickConnectWizard
|
||||
open
|
||||
target={{ hostname: "10.2.0.31" }}
|
||||
keys={[]}
|
||||
identities={[{
|
||||
id: "identity-root",
|
||||
label: "Root devices",
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
created: 1,
|
||||
}]}
|
||||
onConnect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /text-sm font-medium[^>]*>Choose protocol</);
|
||||
assert.match(markup, /text-sm font-medium[^>]*>Credential preset</);
|
||||
});
|
||||
|
||||
802
components/QuickConnectWizard.tsx
Normal file
802
components/QuickConnectWizard.tsx
Normal file
@@ -0,0 +1,802 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Key,
|
||||
Lock,
|
||||
Plug,
|
||||
Plus,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import {
|
||||
buildQuickConnectHost,
|
||||
getQuickConnectDefaultPort,
|
||||
isQuickConnectIdentityUsable,
|
||||
type QuickConnectAuthMethod,
|
||||
type QuickConnectProtocol,
|
||||
} from "../domain/quickConnectHost";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { PROTOCOL_VISUAL_STYLES } from "./protocolVisuals";
|
||||
|
||||
|
||||
|
||||
// Wizard steps
|
||||
type WizardStep = "protocol" | "username" | "knownhost" | "auth";
|
||||
|
||||
interface QuickConnectWizardProps {
|
||||
open: boolean;
|
||||
target: QuickConnectTarget;
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
warnings?: string[];
|
||||
onConnect: (host: Host) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onAddKey?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
open,
|
||||
target,
|
||||
keys,
|
||||
identities,
|
||||
warnings,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
onAddKey,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Wizard state
|
||||
const [step, setStep] = useState<WizardStep>("protocol");
|
||||
const [protocol, setProtocol] = useState<QuickConnectProtocol>("ssh");
|
||||
const [username, setUsername] = useState(target.username || "");
|
||||
const [port, setPort] = useState<number>(target.port || 22);
|
||||
|
||||
// Known host verification state
|
||||
const [knownHostInfo, setKnownHostInfo] = useState<{
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
} | null>(null);
|
||||
|
||||
// Auth state
|
||||
const [authMethod, setAuthMethod] = useState<QuickConnectAuthMethod>("password");
|
||||
const [password, setPassword] = useState("");
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
|
||||
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const selectedIdentity = useMemo(
|
||||
() => identities.find((identity) => identity.id === selectedIdentityId),
|
||||
[identities, selectedIdentityId],
|
||||
);
|
||||
|
||||
// Reset state when target changes
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setStep("protocol");
|
||||
setProtocol("ssh");
|
||||
setUsername(target.username || "");
|
||||
setPort(target.port || 22);
|
||||
setPassword("");
|
||||
setSelectedKeyId(null);
|
||||
setSelectedIdentityId(null);
|
||||
setSaveOptionsOpen(false);
|
||||
setShowPassword(false);
|
||||
setKnownHostInfo(null);
|
||||
}
|
||||
}, [open, target]);
|
||||
|
||||
const canUseSelectedIdentity = useMemo(
|
||||
() => isQuickConnectIdentityUsable(selectedIdentity, keys, protocol),
|
||||
[keys, protocol, selectedIdentity],
|
||||
);
|
||||
const identityOptions = useMemo(
|
||||
() => identities.map((identity) => ({
|
||||
value: identity.id,
|
||||
label: identity.label,
|
||||
sublabel: identity.username,
|
||||
icon: <User size={14} />,
|
||||
})),
|
||||
[identities],
|
||||
);
|
||||
|
||||
const detachSelectedIdentity = () => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthMethod("password");
|
||||
setPassword("");
|
||||
setSelectedKeyId(null);
|
||||
};
|
||||
|
||||
const clearSelectedIdentity = () => {
|
||||
detachSelectedIdentity();
|
||||
setUsername(target.username || "");
|
||||
};
|
||||
|
||||
const handleIdentitySelect = (identityId: string) => {
|
||||
if (!identityId) {
|
||||
clearSelectedIdentity();
|
||||
return;
|
||||
}
|
||||
const identity = identities.find((candidate) => candidate.id === identityId);
|
||||
if (!identity) return;
|
||||
setSelectedIdentityId(identity.id);
|
||||
setUsername(identity.username);
|
||||
setAuthMethod(identity.authMethod);
|
||||
setPassword("");
|
||||
setSelectedKeyId(identity.keyId || null);
|
||||
};
|
||||
|
||||
// Handle protocol selection
|
||||
const handleProtocolSelect = (proto: QuickConnectProtocol) => {
|
||||
setProtocol(proto);
|
||||
if (proto === "telnet") {
|
||||
if (selectedIdentityId) clearSelectedIdentity();
|
||||
setAuthMethod("password");
|
||||
}
|
||||
if (port === getQuickConnectDefaultPort(protocol)) {
|
||||
setPort(getQuickConnectDefaultPort(proto));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProtocolPortChange = (
|
||||
proto: QuickConnectProtocol,
|
||||
rawValue: string,
|
||||
fallbackPort: number,
|
||||
) => {
|
||||
handleProtocolSelect(proto);
|
||||
setPort(parseInt(rawValue) || fallbackPort);
|
||||
};
|
||||
|
||||
// Navigate to next step
|
||||
const handleContinue = () => {
|
||||
switch (step) {
|
||||
case "protocol":
|
||||
if (canUseSelectedIdentity) {
|
||||
handleConnect();
|
||||
break;
|
||||
}
|
||||
// Always go to username step to let user confirm/edit username
|
||||
setStep("username");
|
||||
break;
|
||||
case "username":
|
||||
setStep("auth");
|
||||
break;
|
||||
case "knownhost":
|
||||
setStep("auth");
|
||||
break;
|
||||
case "auth":
|
||||
handleConnect();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate back
|
||||
const handleBack = () => {
|
||||
switch (step) {
|
||||
case "username":
|
||||
setStep("protocol");
|
||||
break;
|
||||
case "knownhost":
|
||||
setStep("username");
|
||||
break;
|
||||
case "auth":
|
||||
// Always go back to username step
|
||||
setStep("username");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Create host and connect
|
||||
const handleConnect = (save = false) => {
|
||||
const effectiveUsername = username || target.username || "root";
|
||||
const effectivePort = port || getQuickConnectDefaultPort(protocol);
|
||||
const now = Date.now();
|
||||
const tempHost = buildQuickConnectHost({
|
||||
id: `quick-${now}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
createdAt: now,
|
||||
target,
|
||||
protocol,
|
||||
port: effectivePort,
|
||||
username: effectiveUsername,
|
||||
authMethod,
|
||||
password,
|
||||
selectedKeyId,
|
||||
selectedIdentityId: selectedIdentity?.id,
|
||||
save,
|
||||
});
|
||||
|
||||
if (save && onSaveHost) {
|
||||
onSaveHost(tempHost);
|
||||
}
|
||||
|
||||
onConnect(tempHost);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Check if can proceed
|
||||
const canProceed = useMemo(() => {
|
||||
switch (step) {
|
||||
case "protocol":
|
||||
return true;
|
||||
case "username":
|
||||
return username.trim().length > 0;
|
||||
case "knownhost":
|
||||
return true;
|
||||
case "auth":
|
||||
if (selectedIdentity && protocol !== "telnet") return canUseSelectedIdentity;
|
||||
if (authMethod === "password") {
|
||||
// Whitespace-only passwords are valid SSH secrets (issue #2036).
|
||||
return password.length > 0;
|
||||
}
|
||||
return Boolean(selectedKeyId && keys.some((key) => key.id === selectedKeyId));
|
||||
}
|
||||
}, [
|
||||
step,
|
||||
username,
|
||||
authMethod,
|
||||
password,
|
||||
selectedKeyId,
|
||||
selectedIdentity,
|
||||
canUseSelectedIdentity,
|
||||
keys,
|
||||
protocol,
|
||||
]);
|
||||
|
||||
// Render protocol selection step
|
||||
const renderProtocolStep = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("protocolSelect.chooseProtocol")}</h3>
|
||||
<div className="space-y-3">
|
||||
{/* SSH */}
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all text-left",
|
||||
protocol === "ssh"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/60 hover:border-border hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => handleProtocolSelect("ssh")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-lg flex items-center justify-center",
|
||||
protocol === "ssh"
|
||||
? PROTOCOL_VISUAL_STYLES.ssh.selected
|
||||
: PROTOCOL_VISUAL_STYLES.ssh.idle,
|
||||
)}
|
||||
>
|
||||
{PROTOCOL_VISUAL_STYLES.ssh.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">SSH</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
ssh {target.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("protocolSelect.port")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={protocol === "ssh" ? port : 22}
|
||||
onChange={(e) => handleProtocolPortChange("ssh", e.target.value, 22)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Mosh */}
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all text-left",
|
||||
protocol === "mosh"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/60 hover:border-border hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => handleProtocolSelect("mosh")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-lg flex items-center justify-center",
|
||||
protocol === "mosh"
|
||||
? PROTOCOL_VISUAL_STYLES.mosh.selected
|
||||
: PROTOCOL_VISUAL_STYLES.mosh.idle,
|
||||
)}
|
||||
>
|
||||
{PROTOCOL_VISUAL_STYLES.mosh.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Mosh</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
mosh {target.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("protocolSelect.port")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={protocol === "mosh" ? port : 22}
|
||||
onChange={(e) => handleProtocolPortChange("mosh", e.target.value, 22)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Eternal Terminal */}
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all text-left",
|
||||
protocol === "et"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/60 hover:border-border hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => handleProtocolSelect("et")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-lg flex items-center justify-center",
|
||||
protocol === "et"
|
||||
? PROTOCOL_VISUAL_STYLES.et.selected
|
||||
: PROTOCOL_VISUAL_STYLES.et.idle,
|
||||
)}
|
||||
>
|
||||
{PROTOCOL_VISUAL_STYLES.et.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Eternal Terminal</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
et {target.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("protocolSelect.port")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={protocol === "et" ? port : 22}
|
||||
onChange={(e) => handleProtocolPortChange("et", e.target.value, 22)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Telnet */}
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all text-left",
|
||||
protocol === "telnet"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/60 hover:border-border hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => handleProtocolSelect("telnet")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-lg flex items-center justify-center",
|
||||
protocol === "telnet"
|
||||
? PROTOCOL_VISUAL_STYLES.telnet.selected
|
||||
: PROTOCOL_VISUAL_STYLES.telnet.idle,
|
||||
)}
|
||||
>
|
||||
{PROTOCOL_VISUAL_STYLES.telnet.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Telnet</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
telnet {target.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("protocolSelect.port")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={protocol === "telnet" ? port : 23}
|
||||
onChange={(e) => handleProtocolPortChange("telnet", e.target.value, 23)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-16 h-7 text-xs text-center"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{protocol !== "telnet" && identities.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-sm font-medium">{t("quickConnect.identity.label")}</Label>
|
||||
<Combobox
|
||||
options={identityOptions}
|
||||
value={selectedIdentityId || undefined}
|
||||
onValueChange={handleIdentitySelect}
|
||||
placeholder={t("quickConnect.identity.placeholder")}
|
||||
emptyText={t("quickConnect.identity.empty")}
|
||||
icon={<User size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedIdentity
|
||||
? t("quickConnect.identity.selectedHint", { username: selectedIdentity.username })
|
||||
: t("quickConnect.identity.hint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render username step
|
||||
const renderUsernameStep = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-username">{t("terminal.auth.username")}</Label>
|
||||
<Input
|
||||
id="quick-username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
if (selectedIdentityId) detachSelectedIdentity();
|
||||
setUsername(e.target.value);
|
||||
}}
|
||||
placeholder={t("terminal.auth.username.placeholder")}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && username.trim()) {
|
||||
handleContinue();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render known host verification step
|
||||
const renderKnownHostStep = () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-amber-600 dark:text-amber-500">
|
||||
{t("quickConnect.knownHost.title")}
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>{t("quickConnect.knownHost.authenticity", { hostname: target.hostname })}</p>
|
||||
{knownHostInfo && (
|
||||
<>
|
||||
<p className="font-medium text-foreground">
|
||||
{t("quickConnect.knownHost.fingerprintLabel", { keyType: knownHostInfo.keyType })}
|
||||
</p>
|
||||
<p className="font-mono text-xs bg-muted p-2 rounded break-all">
|
||||
{knownHostInfo.fingerprint}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p>{t("quickConnect.knownHost.addQuestion")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render auth step
|
||||
const renderAuthStep = () => (
|
||||
<div className="space-y-4">
|
||||
{selectedIdentity ? (
|
||||
<div className="rounded-xl border border-primary/40 bg-primary/5 p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">{selectedIdentity.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{selectedIdentity.username} · {t(`quickConnect.identity.method.${selectedIdentity.authMethod}`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!canUseSelectedIdentity && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("quickConnect.identity.unavailable")}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={detachSelectedIdentity}>
|
||||
{t("quickConnect.identity.useCustom")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Auth method tabs */}
|
||||
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
authMethod === "password"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthMethod("password");
|
||||
}}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t("terminal.auth.password")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
authMethod !== "password"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthMethod("key");
|
||||
}}
|
||||
>
|
||||
<Key size={14} />
|
||||
{t("terminal.auth.sshKey")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
{authMethod === "password" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quick-password">{t("terminal.auth.passwordLabel")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="quick-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("terminal.auth.password.placeholder")}
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && password.length > 0) {
|
||||
handleConnect();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key selection */}
|
||||
{authMethod === "key" && (
|
||||
<div className="space-y-2">
|
||||
{keys.filter((k) => k.category === "key").length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
|
||||
{t("terminal.auth.noKeysHint")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-48">
|
||||
<div className="space-y-2">
|
||||
{keys
|
||||
.filter((k) => k.category === "key")
|
||||
.map((key) => (
|
||||
<button
|
||||
key={key.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
|
||||
selectedKeyId === key.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/50 hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedIdentityId(null);
|
||||
setSelectedKeyId(key.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||
"bg-primary/20 text-primary",
|
||||
)}
|
||||
>
|
||||
<Key size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{key.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Type {key.type}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{onAddKey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-2"
|
||||
onClick={onAddKey}
|
||||
>
|
||||
<Plus size={14} className="mr-2" />
|
||||
Add key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const hostEndpoint = formatHostPort(target.hostname, port || getQuickConnectDefaultPort(protocol));
|
||||
const connectTitle = t("quickConnect.connectTitle", { host: hostEndpoint });
|
||||
const protocolLabel = protocol === "et" ? "ET" : protocol.toUpperCase();
|
||||
const effectiveUsername = username || target.username || "";
|
||||
const connectSubtitle = step === "protocol"
|
||||
? null
|
||||
: (
|
||||
effectiveUsername
|
||||
? `${protocolLabel} ${effectiveUsername}@${hostEndpoint}`
|
||||
: `${protocolLabel} ${hostEndpoint}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-[560px] max-w-[90vw] bg-background border border-border rounded-2xl animate-in fade-in-0 zoom-in-95 duration-200"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 12px 24px -8px rgba(0, 0, 0, 0.15)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Plug size={18} className="shrink-0 text-foreground" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold truncate">{connectTitle}</h2>
|
||||
{connectSubtitle ? (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{connectSubtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{warnings && warnings.length > 0 && (
|
||||
<div className="px-6 pb-2">
|
||||
<div className="text-xs text-amber-600 bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2">
|
||||
{t("quickConnect.warning.unparsedOptions", {
|
||||
options: warnings.join(", "),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4">
|
||||
{step === "protocol" && renderProtocolStep()}
|
||||
{step === "username" && renderUsernameStep()}
|
||||
{step === "knownhost" && renderKnownHostStep()}
|
||||
{step === "auth" && renderAuthStep()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={step === "protocol" ? onClose : handleBack}
|
||||
>
|
||||
{step === "protocol" ? (
|
||||
t("common.close")
|
||||
) : (
|
||||
<>
|
||||
<ArrowLeft size={14} className="mr-2" />
|
||||
{t("common.back")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{step === "knownhost" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={handleContinue}>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Add to known hosts then continue
|
||||
handleContinue();
|
||||
}}
|
||||
>
|
||||
{t("quickConnect.knownHost.addAndContinue")}
|
||||
</Button>
|
||||
</div>
|
||||
) : step === "auth" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{onAddKey &&
|
||||
authMethod === "key" &&
|
||||
keys.filter((k) => k.category === "key").length === 0 && (
|
||||
<Button variant="secondary" onClick={onAddKey}>
|
||||
{t("quickConnect.addKey")}
|
||||
</Button>
|
||||
)}
|
||||
<Dropdown open={saveOptionsOpen} onOpenChange={setSaveOptionsOpen}>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
disabled={!canProceed}
|
||||
onClick={() => handleConnect(false)}
|
||||
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
disabled={!canProceed}
|
||||
aria-label={t("terminal.auth.continueSave")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={saveOptionsOpen}
|
||||
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44 p-1 z-50" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={() => handleConnect(true)}
|
||||
disabled={!canProceed}
|
||||
>
|
||||
{t("terminal.auth.continueSave")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleContinue} disabled={!canProceed}>
|
||||
{step === "protocol" && canUseSelectedIdentity
|
||||
? t("quickConnect.identity.connect")
|
||||
: t("common.continue")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickConnectWizard;
|
||||
153
components/QuickSwitcher.pluginContributions.test.tsx
Normal file
153
components/QuickSwitcher.pluginContributions.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildPaneMagnificationPaletteAction,
|
||||
buildPluginPaletteItems,
|
||||
getQuickSwitcherRowStateClass,
|
||||
shouldUseQuickSwitcherPointerNavigation,
|
||||
} from './QuickSwitcher';
|
||||
|
||||
test('command palette follows the active pane magnification state', () => {
|
||||
const t = (key: string) => key;
|
||||
assert.equal(buildPaneMagnificationPaletteAction('unavailable', t), null);
|
||||
assert.deepEqual(buildPaneMagnificationPaletteAction('focusable', t), {
|
||||
type: 'action',
|
||||
id: 'magnify-current-pane',
|
||||
title: 'terminal.paneMagnification.magnify',
|
||||
});
|
||||
assert.deepEqual(buildPaneMagnificationPaletteAction('focused', t), {
|
||||
type: 'action',
|
||||
id: 'restore-magnified-pane',
|
||||
title: 'terminal.paneMagnification.restore',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the new workspace action outside the scrollable results', () => {
|
||||
const source = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
const actionIndex = source.indexOf('{/* Jump To hint + New Workspace action */}');
|
||||
const resultsScrollIndex = source.indexOf('data-host-picker-virtual="quick-switcher"');
|
||||
|
||||
assert.notEqual(actionIndex, -1);
|
||||
assert.notEqual(resultsScrollIndex, -1);
|
||||
assert.ok(actionIndex < resultsScrollIndex);
|
||||
assert.match(source, /VariableSizeVirtualList/);
|
||||
});
|
||||
|
||||
test('host rows expose optional edit-host context menu', () => {
|
||||
const source = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /onEditHost\?: \(host: Host\) => void/);
|
||||
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
|
||||
assert.match(source, /ContextMenuTrigger asChild/);
|
||||
assert.match(source, /#netcatty-context-menu-root/);
|
||||
});
|
||||
|
||||
test('pointer hover never rewrites the keyboard selection', () => {
|
||||
const quickSwitcherSource = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
const workspacePickerSource = readFileSync(new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.doesNotMatch(quickSwitcherSource, /onMouseEnter/);
|
||||
assert.doesNotMatch(quickSwitcherSource, /selectWithPointer/);
|
||||
assert.doesNotMatch(workspacePickerSource, /onMouseEnter/);
|
||||
assert.doesNotMatch(workspacePickerSource, /onMouseMove/);
|
||||
});
|
||||
|
||||
test('keyboard navigation shows only the current keyboard selection', () => {
|
||||
assert.equal(getQuickSwitcherRowStateClass(true, true), 'bg-primary/15');
|
||||
assert.equal(getQuickSwitcherRowStateClass(false, true), '');
|
||||
assert.equal(getQuickSwitcherRowStateClass(false, false), 'hover:bg-muted/50');
|
||||
});
|
||||
|
||||
test('layout movement under a stationary pointer cannot take over keyboard navigation', () => {
|
||||
assert.equal(shouldUseQuickSwitcherPointerNavigation(0, 0), false);
|
||||
assert.equal(shouldUseQuickSwitcherPointerNavigation(1, 0), true);
|
||||
assert.equal(shouldUseQuickSwitcherPointerNavigation(0, -1), true);
|
||||
});
|
||||
|
||||
function pluginSnapshot(menuEnabled: boolean): NetcattyPluginContributionSnapshot['plugins'] {
|
||||
return [{
|
||||
id: 'com.example.palette',
|
||||
version: '1.0.0',
|
||||
displayName: 'Palette plugin',
|
||||
description: '',
|
||||
commands: [{
|
||||
id: 'com.example.palette.run',
|
||||
title: 'Run command',
|
||||
enabled: true,
|
||||
}],
|
||||
keybindings: [],
|
||||
menus: [{
|
||||
id: 'com.example.palette:menu:0',
|
||||
command: 'com.example.palette.run',
|
||||
alt: 'com.example.palette.runAlternate',
|
||||
location: 'commandPalette',
|
||||
title: 'Run from palette',
|
||||
visible: true,
|
||||
enabled: menuEnabled,
|
||||
shortcut: 'ctrl+shift+r',
|
||||
}],
|
||||
settings: [],
|
||||
views: [],
|
||||
}];
|
||||
}
|
||||
|
||||
test('plugin palette items preserve menu-specific enablement', () => {
|
||||
assert.deepEqual(buildPluginPaletteItems(pluginSnapshot(false), ''), [{
|
||||
type: 'plugin-command',
|
||||
id: 'com.example.palette:menu:0',
|
||||
commandId: 'com.example.palette.run',
|
||||
title: 'Run from palette',
|
||||
pluginTitle: 'Palette plugin',
|
||||
pluginId: 'com.example.palette',
|
||||
enabled: false,
|
||||
altCommand: 'com.example.palette.runAlternate',
|
||||
shortcut: 'ctrl+shift+r',
|
||||
}]);
|
||||
assert.equal(buildPluginPaletteItems(pluginSnapshot(true), '')[0]?.enabled, true);
|
||||
});
|
||||
|
||||
test('plugin palette items honor declared menu ordering', () => {
|
||||
const plugins = pluginSnapshot(true);
|
||||
const plugin = plugins[0];
|
||||
const ordered = [{
|
||||
...plugin,
|
||||
commands: [
|
||||
...plugin.commands,
|
||||
{ id: 'com.example.palette.first', title: 'First command', enabled: true },
|
||||
],
|
||||
menus: [
|
||||
{ ...plugin.menus[0], group: 'navigation', order: 20 },
|
||||
{
|
||||
...plugin.menus[0],
|
||||
id: 'com.example.palette:menu:1',
|
||||
command: 'com.example.palette.first',
|
||||
title: 'First from palette',
|
||||
group: 'navigation',
|
||||
order: 10,
|
||||
},
|
||||
],
|
||||
}];
|
||||
assert.deepEqual(buildPluginPaletteItems(ordered, '').map((item) => item.id), [
|
||||
'com.example.palette:menu:1',
|
||||
'com.example.palette:menu:0',
|
||||
]);
|
||||
});
|
||||
|
||||
test('plugin palette items keep repeated placements independently addressable', () => {
|
||||
const plugins = pluginSnapshot(true);
|
||||
plugins[0].menus.push({
|
||||
...plugins[0].menus[0],
|
||||
id: 'com.example.palette:menu:1',
|
||||
title: 'Run another way',
|
||||
});
|
||||
|
||||
const items = buildPluginPaletteItems(plugins, '');
|
||||
assert.deepEqual(items.map(({ id, commandId }) => ({ id, commandId })), [{
|
||||
id: 'com.example.palette:menu:0',
|
||||
commandId: 'com.example.palette.run',
|
||||
}, {
|
||||
id: 'com.example.palette:menu:1',
|
||||
commandId: 'com.example.palette.run',
|
||||
}]);
|
||||
});
|
||||
777
components/QuickSwitcher.tsx
Normal file
777
components/QuickSwitcher.tsx
Normal file
@@ -0,0 +1,777 @@
|
||||
import {
|
||||
Folder,
|
||||
FolderLock,
|
||||
LayoutGrid,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Plus,
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import type { PaneMagnificationState } from "../domain/paneMagnification";
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
import { requestOpenPluginView } from "./plugins/PluginContributionHost";
|
||||
import { PluginContributionIcon } from "./plugins/PluginContributionIcon";
|
||||
|
||||
type QuickSwitcherItemBase = {
|
||||
id: string;
|
||||
data?: Host | TerminalSession | Workspace;
|
||||
pluginTitle?: string;
|
||||
title?: string;
|
||||
enabled?: boolean;
|
||||
altCommand?: string;
|
||||
shortcut?: string;
|
||||
pluginId?: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
};
|
||||
|
||||
type QuickSwitcherItem = QuickSwitcherItemBase & (
|
||||
| { type: "plugin-command"; commandId: string }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
|
||||
);
|
||||
|
||||
export function buildPaneMagnificationPaletteAction(
|
||||
state: PaneMagnificationState,
|
||||
t: (key: string) => string,
|
||||
): QuickSwitcherItem | null {
|
||||
if (state === 'focusable') {
|
||||
return { type: 'action', id: 'magnify-current-pane', title: t('terminal.paneMagnification.magnify') };
|
||||
}
|
||||
if (state === 'focused') {
|
||||
return { type: 'action', id: 'restore-magnified-pane', title: t('terminal.paneMagnification.restore') };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getQuickSwitcherRowStateClass(
|
||||
isSelected: boolean,
|
||||
isKeyboardNavigating: boolean,
|
||||
): string {
|
||||
if (isSelected) return "bg-primary/15";
|
||||
return isKeyboardNavigating ? "" : "hover:bg-muted/50";
|
||||
}
|
||||
|
||||
export function shouldUseQuickSwitcherPointerNavigation(
|
||||
movementX: number,
|
||||
movementY: number,
|
||||
): boolean {
|
||||
return movementX !== 0 || movementY !== 0;
|
||||
}
|
||||
|
||||
export function buildPluginPaletteItems(
|
||||
plugins: NetcattyPluginContributionSnapshot['plugins'],
|
||||
trimmedQuery: string,
|
||||
): QuickSwitcherItem[] {
|
||||
return plugins.flatMap((plugin) => {
|
||||
const commandById = new Map(plugin.commands.map((command) => [command.id, command] as const));
|
||||
const paletteMenus = plugin.menus
|
||||
.filter((menu) => menu.location === 'commandPalette' && menu.visible)
|
||||
.sort((left, right) => (left.group ?? '').localeCompare(right.group ?? '')
|
||||
|| (left.order ?? 0) - (right.order ?? 0)
|
||||
|| left.id.localeCompare(right.id));
|
||||
const commands: QuickSwitcherItem[] = paletteMenus
|
||||
.map((menu) => ({ menu, command: commandById.get(menu.command) }))
|
||||
.filter((entry): entry is typeof entry & { command: NonNullable<typeof entry.command> } => Boolean(entry.command))
|
||||
.filter(({ menu, command }) => !trimmedQuery || matchesSearchQuery(
|
||||
trimmedQuery,
|
||||
menu.title ?? command.title,
|
||||
command.category ?? '',
|
||||
plugin.displayName,
|
||||
))
|
||||
.map(({ command, menu }) => {
|
||||
const icon = menu.icon ?? command.icon;
|
||||
return {
|
||||
type: 'plugin-command' as const,
|
||||
id: menu.id,
|
||||
commandId: command.id,
|
||||
title: menu.title,
|
||||
pluginTitle: plugin.displayName,
|
||||
pluginId: plugin.id,
|
||||
enabled: command.enabled && menu.enabled,
|
||||
...(icon ? { icon } : {}),
|
||||
...(menu.alt ? { altCommand: menu.alt } : {}),
|
||||
...(menu.shortcut ? { shortcut: menu.shortcut } : {}),
|
||||
};
|
||||
});
|
||||
const views: QuickSwitcherItem[] = plugin.views
|
||||
.filter((view) => view.visible)
|
||||
.filter((view) => !trimmedQuery || matchesSearchQuery(trimmedQuery, view.title, plugin.displayName, view.id))
|
||||
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
|
||||
.map((view) => ({
|
||||
type: 'plugin-view' as const,
|
||||
id: view.id,
|
||||
title: view.title,
|
||||
pluginTitle: plugin.displayName,
|
||||
pluginId: plugin.id,
|
||||
enabled: true,
|
||||
...(view.icon ? { icon: view.icon } : {}),
|
||||
}));
|
||||
return [...commands, ...views];
|
||||
});
|
||||
}
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "./ui/context-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import {
|
||||
VariableSizeVirtualList,
|
||||
type VariableSizeVirtualListHandle,
|
||||
} from "./ui/VariableSizeVirtualList";
|
||||
import { clampListIndex, stepListIndex } from "./ui/virtualListMath";
|
||||
|
||||
// Compute once at module level
|
||||
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
const QS_ROW_HEIGHT = 44;
|
||||
/** Two-line plugin command/view rows (title + subtitle + py-2.5) need a taller slot. */
|
||||
const QS_PLUGIN_ROW_HEIGHT = 56;
|
||||
const QS_HEADER_HEIGHT = 32;
|
||||
|
||||
type QuickSwitcherVisualRow =
|
||||
| { kind: "header"; key: string; label: string }
|
||||
| { kind: "item"; key: string; item: QuickSwitcherItem; itemIndex: number };
|
||||
|
||||
/** Exported for tests — must match VariableSizeVirtualList absolute slot heights. */
|
||||
export function getQuickSwitcherVisualRowHeight(row: QuickSwitcherVisualRow): number {
|
||||
if (row.kind === "header") return QS_HEADER_HEIGHT;
|
||||
if (row.item.type === "plugin-command" || row.item.type === "plugin-view") {
|
||||
return QS_PLUGIN_ROW_HEIGHT;
|
||||
}
|
||||
return QS_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
interface QuickSwitcherProps {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
results: Host[];
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelect: (host: Host) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
onCreateWorkspace?: () => void;
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
terminalSettings?: Pick<TerminalSettings, "localShell" | "localShellArgs">;
|
||||
paneMagnificationState?: PaneMagnificationState;
|
||||
onMagnifyCurrentPane?: () => void;
|
||||
onRestoreMagnifiedPane?: () => void;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
isOpen,
|
||||
query,
|
||||
results,
|
||||
sessions,
|
||||
workspaces,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
onEditHost,
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
terminalSettings,
|
||||
paneMagnificationState = 'unavailable',
|
||||
onMagnifyCurrentPane,
|
||||
onRestoreMagnifiedPane,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
const pluginContributions = usePluginContributions({
|
||||
context: { 'netcatty.surface': 'commandPalette' },
|
||||
});
|
||||
const quickSwitcherShells = useMemo(() => (
|
||||
buildQuickSwitcherShells(
|
||||
discoveredShells,
|
||||
terminalSettings?.localShell ?? "",
|
||||
terminalSettings?.localShellArgs,
|
||||
)
|
||||
), [discoveredShells, terminalSettings?.localShell, terminalSettings?.localShellArgs]);
|
||||
|
||||
const filteredShells = useMemo(() => {
|
||||
const list = !query.trim()
|
||||
? quickSwitcherShells
|
||||
: quickSwitcherShells.filter(
|
||||
(s) => matchesSearchQuery(query, s.name, s.id, s.command)
|
||||
);
|
||||
// Default shell first
|
||||
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
|
||||
}, [quickSwitcherShells, query]);
|
||||
|
||||
// Get hotkey display strings
|
||||
const getHotkeyLabel = useCallback((actionId: string) => {
|
||||
const binding = keyBindings?.find(k => k.id === actionId);
|
||||
if (!binding) return '';
|
||||
return IS_MAC ? binding.mac : binding.pc;
|
||||
}, [keyBindings]);
|
||||
const quickSwitchKey = getHotkeyLabel('quick-switch');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(true);
|
||||
const isKeyboardNavigatingRef = useRef(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<VariableSizeVirtualListHandle>(null);
|
||||
const handlePointerHover = useCallback((movementX: number, movementY: number) => {
|
||||
if (!shouldUseQuickSwitcherPointerNavigation(movementX, movementY)) return;
|
||||
if (!isKeyboardNavigatingRef.current) return;
|
||||
isKeyboardNavigatingRef.current = false;
|
||||
setIsKeyboardNavigating(false);
|
||||
}, []);
|
||||
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
|
||||
setSelectedIndex(0);
|
||||
isKeyboardNavigatingRef.current = true;
|
||||
setIsKeyboardNavigating(true);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle clicks outside the container
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
// ContextMenuContent portals to #netcatty-context-menu-root; treat those
|
||||
// clicks as inside so Connect / Edit host can run before unmount.
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest("#netcatty-context-menu-root")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Memoize orphan sessions
|
||||
const orphanSessions = useMemo(
|
||||
() => sessions.filter((s) => !s.workspaceId && !s.hiddenFromTabs),
|
||||
[sessions]
|
||||
);
|
||||
const trimmedQuery = query.trim();
|
||||
const builtInTabs = useMemo(() => {
|
||||
if (!trimmedQuery) return showSftpTab ? ["vault", "sftp"] : ["vault"];
|
||||
const matched: string[] = [];
|
||||
if (matchesSearchQuery(trimmedQuery, "Vaults", "vault", "hosts", "connections")) {
|
||||
matched.push("vault");
|
||||
}
|
||||
if (showSftpTab && matchesSearchQuery(trimmedQuery, "SFTP", "files", "transfer", "sftp")) {
|
||||
matched.push("sftp");
|
||||
}
|
||||
return matched;
|
||||
}, [showSftpTab, trimmedQuery]);
|
||||
const filteredOrphanSessions = useMemo(() => {
|
||||
if (!trimmedQuery) return orphanSessions;
|
||||
return orphanSessions.filter((session) =>
|
||||
matchesSearchQuery(
|
||||
trimmedQuery,
|
||||
session.hostLabel || "",
|
||||
session.hostname || "",
|
||||
session.id,
|
||||
),
|
||||
);
|
||||
}, [orphanSessions, trimmedQuery]);
|
||||
const filteredWorkspaces = useMemo(() => {
|
||||
if (!trimmedQuery) return workspaces;
|
||||
return workspaces.filter((workspace) =>
|
||||
matchesSearchQuery(trimmedQuery, workspace.title, workspace.id),
|
||||
);
|
||||
}, [trimmedQuery, workspaces]);
|
||||
const shouldShowLocalTerminalFallback = filteredShells.length === 0 && !!onCreateLocalTerminal && !trimmedQuery;
|
||||
const pluginPaletteItems = useMemo(() => buildPluginPaletteItems(
|
||||
pluginContributions.snapshot.plugins,
|
||||
trimmedQuery,
|
||||
), [pluginContributions.snapshot.plugins, trimmedQuery]);
|
||||
const paneMagnificationAction = useMemo(
|
||||
() => buildPaneMagnificationPaletteAction(paneMagnificationState, t),
|
||||
[paneMagnificationState, t],
|
||||
);
|
||||
|
||||
// Memoize flat selectable items + visual rows (headers + items) for virtualization.
|
||||
const { flatItems, visualRows, itemIndexToVisualIndex } = useMemo(() => {
|
||||
const items: QuickSwitcherItem[] = [];
|
||||
const visual: QuickSwitcherVisualRow[] = [];
|
||||
const itemToVisual = new Map<number, number>();
|
||||
|
||||
const pushHeader = (key: string, label: string) => {
|
||||
visual.push({ kind: "header", key, label });
|
||||
};
|
||||
const pushItem = (item: QuickSwitcherItem) => {
|
||||
const itemIndex = items.length;
|
||||
items.push(item);
|
||||
itemToVisual.set(itemIndex, visual.length);
|
||||
visual.push({
|
||||
kind: "item",
|
||||
key: `${item.type}:${item.id}`,
|
||||
item,
|
||||
itemIndex,
|
||||
});
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", "Hosts");
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
}
|
||||
|
||||
const hasTabsSection =
|
||||
builtInTabs.length > 0
|
||||
|| filteredOrphanSessions.length > 0
|
||||
|| filteredWorkspaces.length > 0;
|
||||
if (hasTabsSection) {
|
||||
pushHeader("header:tabs", "Tabs");
|
||||
builtInTabs.forEach((tabId) => {
|
||||
pushItem({ type: "tab", id: tabId });
|
||||
});
|
||||
filteredWorkspaces.forEach((w) =>
|
||||
pushItem({ type: "workspace", id: w.id, data: w }),
|
||||
);
|
||||
filteredOrphanSessions.forEach((s) =>
|
||||
pushItem({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredShells.length > 0) {
|
||||
pushHeader("header:shells", t("qs.localShells"));
|
||||
filteredShells.forEach((shell) =>
|
||||
pushItem({ type: "shell", id: shell.id }),
|
||||
);
|
||||
} else if (shouldShowLocalTerminalFallback) {
|
||||
pushHeader("header:shells", t("qs.localShells"));
|
||||
pushItem({ type: "action", id: "local-terminal" });
|
||||
}
|
||||
|
||||
if (paneMagnificationAction && (
|
||||
!trimmedQuery
|
||||
|| matchesSearchQuery(trimmedQuery, paneMagnificationAction.title ?? '', 'pane', 'magnify', 'zoom')
|
||||
)) {
|
||||
pushHeader("header:pane-actions", t("settings.shortcuts.category.navigation"));
|
||||
pushItem(paneMagnificationAction);
|
||||
}
|
||||
|
||||
if (pluginPaletteItems.length > 0) {
|
||||
pushHeader("header:plugins", t("settings.tab.plugins"));
|
||||
pluginPaletteItems.forEach((item) => pushItem(item));
|
||||
}
|
||||
|
||||
return {
|
||||
flatItems: items,
|
||||
visualRows: visual,
|
||||
itemIndexToVisualIndex: itemToVisual,
|
||||
};
|
||||
}, [
|
||||
builtInTabs,
|
||||
filteredOrphanSessions,
|
||||
filteredShells,
|
||||
filteredWorkspaces,
|
||||
pluginPaletteItems,
|
||||
paneMagnificationAction,
|
||||
results,
|
||||
shouldShowLocalTerminalFallback,
|
||||
t,
|
||||
trimmedQuery,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedIndex((prev) => clampListIndex(prev, flatItems.length));
|
||||
}, [flatItems.length, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const visualIndex = itemIndexToVisualIndex.get(selectedIndex);
|
||||
if (visualIndex === undefined) return;
|
||||
listRef.current?.scrollToIndex(visualIndex, "auto");
|
||||
}, [isOpen, itemIndexToVisualIndex, selectedIndex]);
|
||||
|
||||
const shellById = useMemo(
|
||||
() => new Map(quickSwitcherShells.map((shell) => [shell.id, shell])),
|
||||
[quickSwitcherShells],
|
||||
);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
(row: QuickSwitcherVisualRow) => getQuickSwitcherVisualRowHeight(row),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
isKeyboardNavigatingRef.current = true;
|
||||
setIsKeyboardNavigating(true);
|
||||
setSelectedIndex((prev) => stepListIndex(prev, flatItems.length, 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
isKeyboardNavigatingRef.current = true;
|
||||
setIsKeyboardNavigating(true);
|
||||
setSelectedIndex((prev) => stepListIndex(prev, flatItems.length, -1));
|
||||
} else if (e.key === "Enter" && flatItems.length > 0) {
|
||||
e.preventDefault();
|
||||
const item = flatItems[clampListIndex(selectedIndex, flatItems.length)];
|
||||
if (!item) return;
|
||||
handleItemSelect(item, e.altKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
|
||||
switch (item.type) {
|
||||
case "host":
|
||||
onSelect(item.data as Host);
|
||||
break;
|
||||
case "tab":
|
||||
case "workspace":
|
||||
onSelectTab(item.id);
|
||||
onClose();
|
||||
break;
|
||||
case "action":
|
||||
if (item.id === "local-terminal" && onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
} else if (item.id === 'magnify-current-pane' && onMagnifyCurrentPane) {
|
||||
onMagnifyCurrentPane();
|
||||
onClose();
|
||||
} else if (item.id === 'restore-magnified-pane' && onRestoreMagnifiedPane) {
|
||||
onRestoreMagnifiedPane();
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
case "shell": {
|
||||
const shell = quickSwitcherShells.find(s => s.id === item.id);
|
||||
if (shell && onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "plugin-command":
|
||||
if (item.enabled !== false) {
|
||||
void pluginContributions.executeCommand(
|
||||
useAlternate && item.altCommand ? item.altCommand : item.commandId,
|
||||
undefined,
|
||||
{ 'netcatty.surface': 'commandPalette' },
|
||||
).catch(() => {});
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
case "plugin-view":
|
||||
requestOpenPluginView({ viewId: item.id, context: { 'netcatty.surface': 'commandPalette' } });
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full max-w-2xl mx-4 bg-background border border-border rounded-xl shadow-2xl overflow-hidden max-h-[520px] flex flex-col"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
onMouseMove={(event) => handlePointerHover(event.movementX, event.movementY)}
|
||||
>
|
||||
{/* Search Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
||||
<Search size={16} className="text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
onQueryChange(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
isKeyboardNavigatingRef.current = true;
|
||||
setIsKeyboardNavigating(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("qs.search.placeholder")}
|
||||
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
|
||||
/>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[11px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Jump To hint + New Workspace action */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-4 py-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
{onCreateWorkspace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCreateWorkspace();
|
||||
onClose();
|
||||
}}
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
max-h on the list is required: the popup only has max-h-[520px], so a bare
|
||||
h-full child can expand with the virtual spacer and get clipped with no scroll.
|
||||
Cap the scroller so large inventories remain reachable.
|
||||
*/}
|
||||
<div className="min-h-0 flex-1 overflow-hidden" data-host-picker-virtual="quick-switcher">
|
||||
<VariableSizeVirtualList<QuickSwitcherVisualRow>
|
||||
ref={listRef}
|
||||
items={visualRows}
|
||||
getItemHeight={getRowHeight}
|
||||
className="h-full max-h-[min(360px,calc(100vh-14rem))]"
|
||||
overscan={8}
|
||||
getItemKey={(row) => row.key}
|
||||
renderItem={(row) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<div className="flex h-full items-end px-4 pb-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{row.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { item, itemIndex } = row;
|
||||
const isSelected = itemIndex === selectedIndex;
|
||||
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow.
|
||||
const rowClass = `flex h-full min-h-0 items-center gap-3 overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`;
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
const hostRow = (
|
||||
<div
|
||||
className={`flex h-full min-h-0 items-center justify-between overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
|
||||
onClick={() => onSelect(host)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">{host.label}</span>
|
||||
</div>
|
||||
<div className="ml-3 max-w-[12rem] shrink-0 truncate text-[11px] text-muted-foreground">
|
||||
{host.group ? `Personal / ${host.group}` : "Personal"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!onEditHost) return hostRow;
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{hostRow}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onSelect(host)}>
|
||||
{t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
{t('terminal.layer.hostTree.editHost')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "tab") {
|
||||
const isBuiltIn = item.id === "vault" || item.id === "sftp";
|
||||
if (isBuiltIn) {
|
||||
const icon = item.id === "vault" ? <FolderLock size={16} /> : <Folder size={16} />;
|
||||
const label = item.id === "vault" ? "Vaults" : "SFTP";
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => {
|
||||
onSelectTab(item.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="truncate text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const session = item.data as TerminalSession | undefined;
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => {
|
||||
onSelectTab(item.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{session?.hostLabel ?? item.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "workspace") {
|
||||
const workspace = item.data as Workspace | undefined;
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => {
|
||||
onSelectTab(item.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{workspace?.title ?? item.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "shell") {
|
||||
const shell = shellById.get(item.id);
|
||||
if (!shell) return null;
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => {
|
||||
if (onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({
|
||||
command: shell.command,
|
||||
args: shell.args,
|
||||
name: shell.name,
|
||||
icon: shell.icon,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getShellIconPath(shell.icon)}
|
||||
alt={shell.name}
|
||||
className={`h-6 w-6 shrink-0${isMonochromeShellIcon(shell.icon) ? " dark:invert" : ""}`}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{shell.name}</span>
|
||||
{shell.isDefault && (
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{t("qs.default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "action" && item.id === "local-terminal") {
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal?.();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="truncate text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'action' && (
|
||||
item.id === 'magnify-current-pane' || item.id === 'restore-magnified-pane'
|
||||
)) {
|
||||
const restoring = item.id === 'restore-magnified-pane';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${rowClass} flex h-full w-full items-center gap-3 px-4 text-left`}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
{restoring ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
|
||||
<kbd className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{getHotkeyLabel('toggle-pane-zoom').replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "plugin-command" || item.type === "plugin-view") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={item.enabled === false}
|
||||
className={`flex h-full min-h-0 w-full items-center gap-3 overflow-hidden px-4 py-2.5 text-left transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)} disabled:opacity-50`}
|
||||
onClick={(event) => handleItemSelect(item, event.altKey)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<PluginContributionIcon pluginId={item.pluginId} icon={item.icon} size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{item.pluginTitle}</div>
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<kbd className="shrink-0 text-[10px] text-muted-foreground">{item.shortcut}</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuickSwitcher = memo(QuickSwitcherInner);
|
||||
QuickSwitcher.displayName = "QuickSwitcher";
|
||||
30
components/ScriptMetaFields.test.tsx
Normal file
30
components/ScriptMetaFields.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Snippet } from "../domain/models.ts";
|
||||
import { ScriptMetaFields } from "./scripts/ScriptMetaFields.tsx";
|
||||
|
||||
const snippet: Snippet = {
|
||||
id: "script",
|
||||
label: "Deploy",
|
||||
command: "echo ok",
|
||||
trigger: "onOutput",
|
||||
triggerPattern: "ready",
|
||||
};
|
||||
|
||||
test("toolbar script metadata keeps output trigger fields on a full row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<ScriptMetaFields snippet={snippet} onChange={() => {}} layout="toolbar" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(
|
||||
markup,
|
||||
/sm:grid-cols-\[minmax\(0,1fr\)_148px\]/,
|
||||
);
|
||||
assert.match(markup, /col-span-full space-y-1/);
|
||||
});
|
||||
163
components/ScriptsSidePanel.test.ts
Normal file
163
components/ScriptsSidePanel.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
buildScriptsSidePanelRows,
|
||||
collectScriptsSidePanelPackagePaths,
|
||||
} from "./ScriptsSidePanel.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
|
||||
const snippet = (overrides: Partial<Snippet>): Snippet => ({
|
||||
id: overrides.id ?? "snippet",
|
||||
label: overrides.label ?? "Snippet",
|
||||
command: overrides.command ?? "echo ok",
|
||||
package: overrides.package ?? "",
|
||||
order: overrides.order,
|
||||
});
|
||||
|
||||
const source = readFileSync(new URL("./ScriptsSidePanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("scripts side panel rows keep manual snippet order inside a package", () => {
|
||||
const rows = buildScriptsSidePanelRows({
|
||||
snippets: [
|
||||
snippet({ id: "alpha", label: "Alpha", package: "ops", order: 3000 }),
|
||||
snippet({ id: "zulu", label: "Zulu", package: "ops", order: 1000 }),
|
||||
snippet({ id: "beta", label: "Beta", package: "ops", order: 2000 }),
|
||||
],
|
||||
packages: ["ops"],
|
||||
expandedPaths: new Set(["ops"]),
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rows.filter((row) => row.type === "snippet").map((row) => row.id),
|
||||
["zulu", "beta", "alpha"],
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel active tabs pair the accent background with its foreground", () => {
|
||||
assert.equal(source.match(/bg-accent text-accent-foreground/g)?.length, 2);
|
||||
});
|
||||
|
||||
test("scripts side panel offers run-on-all-tabs for every snippet, not only automation scripts", () => {
|
||||
// Guard against re-introducing the isScriptSnippet gate on workspace run actions.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onRunParallel=\{isScriptSnippet\(item\.row\.snippet\) && onRunScriptOnWorkspace/,
|
||||
);
|
||||
assert.match(source, /onRunParallel=\{onRunScriptOnWorkspace/);
|
||||
// Sequential remains script-only (mode is meaningful only for automation runs).
|
||||
assert.match(source, /onRunSequential=\{isScript && onRunScriptOnWorkspace/);
|
||||
assert.match(source, /scripts\.actions\.runOnAllTabs/);
|
||||
assert.match(source, /snippets\.action\.newPackage/);
|
||||
assert.match(source, /openPackageDialog/);
|
||||
});
|
||||
|
||||
test("scripts side panel exposes create actions as inline toolbar icons", () => {
|
||||
assert.match(source, /snippets\.action\.newSnippet/);
|
||||
assert.match(source, /snippets\.action\.newPackage/);
|
||||
assert.match(source, /snippets\.action\.newScript/);
|
||||
assert.match(source, /openPackageDialog/);
|
||||
assert.match(source, /handleAddScript/);
|
||||
assert.doesNotMatch(source, /ChevronDown/);
|
||||
assert.doesNotMatch(source, /DropdownTrigger/);
|
||||
});
|
||||
|
||||
test("collectScriptsSidePanelPackagePaths includes implied ancestors", () => {
|
||||
assert.deepEqual(
|
||||
collectScriptsSidePanelPackagePaths(["ops/linux"], [
|
||||
snippet({ id: "a", package: "ops/linux/disk" }),
|
||||
snippet({ id: "b", package: "" }),
|
||||
]).sort(),
|
||||
["ops", "ops/linux", "ops/linux/disk"],
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel search is icon-toggled and expands below the toolbar", () => {
|
||||
assert.match(source, /searchExpanded/);
|
||||
assert.match(source, /setSearchExpanded/);
|
||||
assert.match(source, /max-h-0 opacity-0/);
|
||||
assert.match(source, /snippets\.searchPlaceholder/);
|
||||
});
|
||||
|
||||
test("scripts side panel shows delete in a selection bar only when snippets are selected", () => {
|
||||
assert.match(source, /selectedSnippetIds\.size > 0/);
|
||||
assert.match(source, /snippets\.selection\.selected[\s\S]*?deleteSelectedSnippets/);
|
||||
assert.match(source, /snippets\.selection\.deleteSelected/);
|
||||
// Trash must not sit between multi-select and add on the permanent icon row.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/snippets\.action\.selectSnippets[\s\S]*?Trash2[\s\S]*?snippets\.action\.newSnippet/,
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel toolbar exposes expand and collapse actions", () => {
|
||||
assert.match(source, /vault\.tree\.expandAll/);
|
||||
assert.match(source, /vault\.tree\.collapseAll/);
|
||||
assert.match(source, /expandAllGroups/);
|
||||
assert.match(source, /collapseAllGroups/);
|
||||
assert.match(source, /isMultiSelectMode/);
|
||||
});
|
||||
|
||||
test("scripts side panel confirms bulk delete and routes it through the shared delete event", () => {
|
||||
// Mass-delete must confirm first (SnippetsManager parity) and must not bypass
|
||||
// AppSideEffects via onSnippetsChange filtering — that path skips host binding cleanup.
|
||||
assert.match(source, /VaultDeleteConfirmDialog/);
|
||||
assert.match(source, /snippets\.selection\.deleteConfirmTitle/);
|
||||
assert.match(source, /snippets\.selection\.deleteConfirmDesc/);
|
||||
assert.match(source, /detail:\s*\{\s*ids\s*\}/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onSnippetsChange\(snippets\.filter\(\(snippet\) => !selectedSnippetIds\.has/,
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts side panel defers bulk-delete confirm when a parent owns the dialog", () => {
|
||||
// Compact TerminalToolbar nests this panel in a Popover; the portalled confirm
|
||||
// must be owned outside that tree so focus cannot unmount the prompt.
|
||||
assert.match(source, /onBulkDeleteRequest\?:/);
|
||||
assert.match(source, /if\s*\(\s*onBulkDeleteRequest\s*\)\s*\{/);
|
||||
assert.match(source, /onBulkDeleteRequest\(ids\)/);
|
||||
assert.match(source, /!onBulkDeleteRequest/);
|
||||
});
|
||||
|
||||
test("scripts side panel clears pending bulk delete when the panel hides", () => {
|
||||
// Returning null while isVisible is false unmounts the confirm dialog; drop
|
||||
// pending deletes so a later re-show does not resurrect a half-dismissed prompt.
|
||||
assert.match(
|
||||
source,
|
||||
/if\s*\(\s*isVisible\s*\)\s*return/,
|
||||
);
|
||||
assert.match(source, /setIsPackageDialogOpen\(\s*false\s*\)/);
|
||||
assert.match(source, /setRenamingPackagePath\(\s*''\s*\)/);
|
||||
assert.match(source, /setNewPackageName\(\s*''\s*\)/);
|
||||
});
|
||||
|
||||
test("scripts side panel package dialog traps focus and exposes dialog close contract", () => {
|
||||
assert.match(source, /packageDialogRef/);
|
||||
assert.match(source, /data-dialog-close="true"/);
|
||||
assert.match(source, /isPackageDialogOpen/);
|
||||
});
|
||||
|
||||
test("scripts side panel package rows wrap in a rename and delete context menu", () => {
|
||||
assert.match(source, /const PackageRow = memo/);
|
||||
assert.match(
|
||||
source,
|
||||
/data-pkg-path=\{row\.path\}[\s\S]*?<ContextMenu>[\s\S]*?<ContextMenuTrigger asChild>[\s\S]*?\{rowButton\}/,
|
||||
);
|
||||
assert.match(source, /common\.rename/);
|
||||
assert.match(source, /vault\.deleteConfirm\.packageDesc/);
|
||||
assert.match(source, /deleteSnippetPackage/);
|
||||
assert.match(source, /renameSnippetPackage/);
|
||||
assert.match(source, /SNIPPET_PACKAGE_PATH_CHANGE_EVENT/);
|
||||
assert.match(source, /detail: \{ from: renamingPackagePath, to: result\.newPath \}/);
|
||||
assert.match(source, /detail: \{ from: path, to: null \}/);
|
||||
assert.match(source, /openRenamePackageDialog/);
|
||||
assert.match(source, /requestDeletePackage/);
|
||||
});
|
||||
|
||||
test("scripts side panel does not start a drag from a non-primary pointer", () => {
|
||||
assert.match(source, /from '\.\/ui\/primaryOnlyDrag'/);
|
||||
assert.match(source, /isNonPrimaryPointer/);
|
||||
assert.match(source, /primaryOnlyDragHandlers/);
|
||||
});
|
||||
1619
components/ScriptsSidePanel.tsx
Normal file
1619
components/ScriptsSidePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
168
components/SelectGroupDialog.tsx
Normal file
168
components/SelectGroupDialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { CheckSquare, FolderTree, Search, Square } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/application/i18n/I18nProvider';
|
||||
import type { Host } from '@/domain/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FixedSizeVirtualList } from '@/components/ui/FixedSizeVirtualList';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SelectGroupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
selectedGroupPaths: string[];
|
||||
onSelectionChange: (selectedGroupPaths: string[]) => void;
|
||||
}
|
||||
|
||||
const GROUP_ROW_HEIGHT = 48;
|
||||
|
||||
function collectGroupPaths(
|
||||
hosts: Host[],
|
||||
customGroups: string[],
|
||||
selectedGroupPaths: string[],
|
||||
): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const groupPath of [...customGroups, ...selectedGroupPaths]) {
|
||||
const parts = groupPath.split('/').filter(Boolean);
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
paths.add(parts.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
for (const host of hosts) {
|
||||
const parts = host.group?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
paths.add(parts.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
return [...paths].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function countHostsByGroupPath(hosts: Host[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const host of hosts) {
|
||||
if (host.protocol === 'serial') continue;
|
||||
const parts = host.group?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
const path = parts.slice(0, index).join('/');
|
||||
counts.set(path, (counts.get(path) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export const SelectGroupDialog: React.FC<SelectGroupDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
hosts,
|
||||
customGroups = [],
|
||||
selectedGroupPaths,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const selected = useMemo(() => new Set(selectedGroupPaths), [selectedGroupPaths]);
|
||||
const groupPaths = useMemo(
|
||||
() => collectGroupPaths(hosts, customGroups, selectedGroupPaths),
|
||||
[customGroups, hosts, selectedGroupPaths],
|
||||
);
|
||||
const displayedPaths = useMemo(() => {
|
||||
const query = searchQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return groupPaths;
|
||||
return groupPaths.filter((path) => path.toLocaleLowerCase().includes(query));
|
||||
}, [groupPaths, searchQuery]);
|
||||
const hostCountByPath = useMemo(() => countHostsByGroupPath(hosts), [hosts]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
if (selected.has(path)) {
|
||||
onSelectionChange(selectedGroupPaths.filter((candidate) => candidate !== path));
|
||||
return;
|
||||
}
|
||||
onSelectionChange([...selectedGroupPaths, path]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md h-[min(78vh,600px)] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4 pb-3 shrink-0 border-b border-border/60">
|
||||
<DialogTitle className="text-base">{t('snippets.targets.selectGroups')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 py-3 border-b border-border/60 shrink-0">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
{displayedPaths.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t('snippets.targets.noGroups')}
|
||||
</p>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
items={displayedPaths}
|
||||
itemHeight={GROUP_ROW_HEIGHT}
|
||||
getItemKey={(path) => path}
|
||||
renderItem={(path) => {
|
||||
const isSelected = selected.has(path);
|
||||
const depth = Math.max(0, path.split('/').filter(Boolean).length - 1);
|
||||
const label = path.split('/').filter(Boolean).pop() ?? path;
|
||||
const hostCount = hostCountByPath.get(path) ?? 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full h-11 flex items-center gap-2.5 rounded-lg px-2.5 text-left transition-colors',
|
||||
isSelected ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
|
||||
)}
|
||||
style={{ paddingLeft: `${10 + depth * 16}px` }}
|
||||
onClick={() => togglePath(path)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={16} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Square size={16} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<FolderTree size={16} className="text-primary shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{path}</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">
|
||||
{t('vault.groups.hostsCount', { count: hostCount })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t('selectHost.continueWithCount', { count: selectedGroupPaths.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
44
components/SelectHostDialog.tsx
Normal file
44
components/SelectHostDialog.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useI18n } from '@/application/i18n/I18nProvider';
|
||||
import { SelectHostPanelContent, type SelectHostPanelContentProps } from './SelectHostPanelContent';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
|
||||
export interface SelectHostDialogProps extends Omit<SelectHostPanelContentProps, 'className'> {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const SelectHostDialog: React.FC<SelectHostDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
onConfirm,
|
||||
...contentProps
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const dialogTitle = title ?? t('snippets.targets.add');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4 pb-2 shrink-0 border-b border-border/60">
|
||||
<DialogTitle className="text-base">{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SelectHostPanelContent
|
||||
{...contentProps}
|
||||
className="flex-1 min-h-0"
|
||||
onConfirm={() => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
112
components/SelectHostPanel.tsx
Normal file
112
components/SelectHostPanel.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, ProxyProfile, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { AsidePanel, type AsidePanelLayout } from "./ui/aside-panel";
|
||||
import { SelectHostPanelContent } from "./SelectHostPanelContent";
|
||||
|
||||
interface SelectHostPanelProps {
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
selectedHostIds?: string[];
|
||||
multiSelect?: boolean;
|
||||
onSelect: (host: Host) => void;
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onBack: () => void;
|
||||
onContinue?: () => void;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
resizable?: boolean;
|
||||
persistWidthStorageKey?: string;
|
||||
resizeAriaLabel?: string;
|
||||
}
|
||||
|
||||
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
hosts,
|
||||
customGroups = [],
|
||||
selectedHostIds = [],
|
||||
multiSelect = false,
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onBack,
|
||||
onContinue,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
proxyProfiles = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
width,
|
||||
layout = "overlay",
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newHostPanelOpen, setNewHostPanelOpen] = useState(false);
|
||||
const panelTitle = title ?? t("selectHost.title");
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
return;
|
||||
}
|
||||
const host = hosts
|
||||
.filter((entry) => entry.protocol !== "serial")
|
||||
.find((entry) => selectedHostIds.includes(entry.id));
|
||||
if (host) onSelect(host);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onBack}
|
||||
title={panelTitle}
|
||||
subtitle={subtitle}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
className={cn(layout === "overlay" && "z-40", newHostPanelOpen && "overflow-visible", className)}
|
||||
width={width}
|
||||
layout={layout}
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
>
|
||||
<SelectHostPanelContent
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
selectedHostIds={selectedHostIds}
|
||||
multiSelect={multiSelect}
|
||||
onSelect={onSelect}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConfirm={handleConfirm}
|
||||
onNewHost={onNewHost}
|
||||
availableKeys={availableKeys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onNewHostPanelOpenChange={setNewHostPanelOpen}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectHostPanel;
|
||||
666
components/SelectHostPanelContent.tsx
Normal file
666
components/SelectHostPanelContent.tsx
Normal file
@@ -0,0 +1,666 @@
|
||||
import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
MinusSquare,
|
||||
Plus,
|
||||
Search,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { matchesHostSearchQuery, matchesSearchQuery } from '../lib/searchMatcher';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
collectSelectableHostIdsInGroup,
|
||||
getGroupSelectionState,
|
||||
toggleIdsInSelection,
|
||||
} from '../domain/selectHostSelection';
|
||||
import { Host, ProxyProfile, SSHKey } from '../types';
|
||||
import { ManagedSource } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import HostDetailsPanel from './HostDetailsPanel';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { SortDropdown, SortMode } from './ui/sort-dropdown';
|
||||
import { TagFilterDropdown } from './ui/tag-filter-dropdown';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from './ui/tooltip';
|
||||
import {
|
||||
VariableSizeVirtualList,
|
||||
type VariableSizeVirtualListHandle,
|
||||
} from './ui/VariableSizeVirtualList';
|
||||
import { clampListIndex, stepListIndex } from './ui/virtualListMath';
|
||||
|
||||
const SELECT_HOST_SECTION_HEIGHT = 28;
|
||||
const SELECT_HOST_ROW_HEIGHT = 48;
|
||||
|
||||
export interface SelectHostPanelContentProps {
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
selectedHostIds: string[];
|
||||
multiSelect?: boolean;
|
||||
onSelect: (host: Host) => void;
|
||||
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onConfirm: () => void;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
onNewHostPanelOpenChange?: (open: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SelectHostListRow =
|
||||
| { kind: 'section'; key: string; title: string }
|
||||
| { kind: 'group'; key: string; path: string; name: string; count: number }
|
||||
| { kind: 'host'; key: string; host: Host };
|
||||
|
||||
type SelectHostNavigableRow = Extract<SelectHostListRow, { kind: 'group' | 'host' }>;
|
||||
|
||||
/** Shared host-picker body used by aside panel and dialog variants. */
|
||||
export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
hosts,
|
||||
customGroups = [],
|
||||
selectedHostIds,
|
||||
multiSelect = false,
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onConfirm,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
proxyProfiles = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
onNewHostPanelOpenChange,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('az');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [showNewHostPanel, setShowNewHostPanel] = useState(false);
|
||||
const [activeNavIndex, setActiveNavIndex] = useState(0);
|
||||
const listRef = useRef<VariableSizeVirtualListHandle>(null);
|
||||
const listboxId = useId();
|
||||
// Index-based IDs stay unique even when group paths only differ by chars that
|
||||
// would collide under a sanitize-to-_ mapping (e.g. "Prod East" vs "Prod_East").
|
||||
const optionDomId = useCallback((navIndex: number) => (
|
||||
`${listboxId}-opt-${navIndex}`
|
||||
), [listboxId]);
|
||||
|
||||
useEffect(() => {
|
||||
onNewHostPanelOpenChange?.(showNewHostPanel);
|
||||
}, [onNewHostPanelOpenChange, showNewHostPanel]);
|
||||
|
||||
const selectableHosts = useMemo(
|
||||
() => hosts.filter((host) => host.protocol !== 'serial'),
|
||||
[hosts],
|
||||
);
|
||||
const selectedHostIdSet = useMemo(
|
||||
() => new Set(selectedHostIds),
|
||||
[selectedHostIds],
|
||||
);
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
selectableHosts.forEach((host) => {
|
||||
host.tags?.forEach((tag) => tagSet.add(tag));
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
}, [selectableHosts]);
|
||||
|
||||
const allGroupPaths = useMemo(() => {
|
||||
const pathSet = new Set<string>();
|
||||
selectableHosts.forEach((host) => {
|
||||
if (host.group) {
|
||||
const parts = host.group.split('/');
|
||||
for (let i = 1; i <= parts.length; i += 1) {
|
||||
pathSet.add(parts.slice(0, i).join('/'));
|
||||
}
|
||||
}
|
||||
});
|
||||
customGroups.forEach((group) => pathSet.add(group));
|
||||
return Array.from(pathSet).sort();
|
||||
}, [selectableHosts, customGroups]);
|
||||
|
||||
const groupHostCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
selectableHosts.forEach((host) => {
|
||||
if (!host.group) return;
|
||||
const parts = host.group.split('/');
|
||||
for (let i = 1; i <= parts.length; i += 1) {
|
||||
const path = parts.slice(0, i).join('/');
|
||||
counts.set(path, (counts.get(path) ?? 0) + 1);
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [selectableHosts]);
|
||||
|
||||
const groupsWithCounts = useMemo(() => {
|
||||
const prefix = currentPath ? `${currentPath}/` : '';
|
||||
const groups: { path: string; name: string; count: number }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
allGroupPaths.forEach((path) => {
|
||||
if (currentPath === null) {
|
||||
const topLevel = path.split('/')[0];
|
||||
if (!seen.has(topLevel)) {
|
||||
seen.add(topLevel);
|
||||
groups.push({ path: topLevel, name: topLevel, count: groupHostCounts.get(topLevel) ?? 0 });
|
||||
}
|
||||
} else if (path.startsWith(prefix) && path !== currentPath) {
|
||||
const rest = path.slice(prefix.length);
|
||||
const nextLevel = rest.split('/')[0];
|
||||
const fullPath = `${prefix}${nextLevel}`;
|
||||
if (!seen.has(fullPath)) {
|
||||
seen.add(fullPath);
|
||||
groups.push({ path: fullPath, name: nextLevel, count: groupHostCounts.get(fullPath) ?? 0 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [allGroupPaths, currentPath, groupHostCounts]);
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
let result = selectableHosts;
|
||||
|
||||
if (currentPath) {
|
||||
result = result.filter(
|
||||
(host) => host.group === currentPath || host.group?.startsWith(`${currentPath}/`),
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
result = result.filter(
|
||||
(host) =>
|
||||
matchesHostSearchQuery(searchQuery, host)
|
||||
|| matchesSearchQuery(searchQuery, host.username, host.notes),
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
result = result.filter(
|
||||
(host) => host.tags && selectedTags.some((tag) => host.tags.includes(tag)),
|
||||
);
|
||||
}
|
||||
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return b.id.localeCompare(a.id);
|
||||
case 'oldest':
|
||||
return a.id.localeCompare(b.id);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [selectableHosts, currentPath, searchQuery, selectedTags, sortMode]);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!currentPath) return [];
|
||||
const parts = currentPath.split('/');
|
||||
return parts.map((part, index) => ({
|
||||
name: part,
|
||||
path: parts.slice(0, index + 1).join('/'),
|
||||
}));
|
||||
}, [currentPath]);
|
||||
|
||||
const groupHostIdsByPath = useMemo(() => {
|
||||
if (!multiSelect) return new Map<string, string[]>();
|
||||
const map = new Map<string, string[]>();
|
||||
for (const group of groupsWithCounts) {
|
||||
map.set(group.path, collectSelectableHostIdsInGroup(selectableHosts, group.path));
|
||||
}
|
||||
return map;
|
||||
}, [multiSelect, groupsWithCounts, selectableHosts]);
|
||||
|
||||
const listRows = useMemo<SelectHostListRow[]>(() => {
|
||||
const rows: SelectHostListRow[] = [];
|
||||
if (groupsWithCounts.length > 0) {
|
||||
rows.push({
|
||||
kind: 'section',
|
||||
key: 'section:groups',
|
||||
title: t('vault.groups.title'),
|
||||
});
|
||||
for (const group of groupsWithCounts) {
|
||||
rows.push({
|
||||
kind: 'group',
|
||||
key: `group:${group.path}`,
|
||||
path: group.path,
|
||||
name: group.name,
|
||||
count: group.count,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (filteredHosts.length > 0) {
|
||||
rows.push({
|
||||
kind: 'section',
|
||||
key: 'section:hosts',
|
||||
title: t('vault.nav.hosts'),
|
||||
});
|
||||
for (const host of filteredHosts) {
|
||||
rows.push({
|
||||
kind: 'host',
|
||||
key: `host:${host.id}`,
|
||||
host,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [filteredHosts, groupsWithCounts, t]);
|
||||
|
||||
const applySelectionChange = useCallback((nextSelectedHostIds: string[]) => {
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(nextSelectedHostIds);
|
||||
return;
|
||||
}
|
||||
// Fallback for callers that only implement per-host toggle: sync by flipping diffs.
|
||||
const prev = new Set(selectedHostIds);
|
||||
const next = new Set(nextSelectedHostIds);
|
||||
for (const host of selectableHosts) {
|
||||
const wasSelected = prev.has(host.id);
|
||||
const isSelected = next.has(host.id);
|
||||
if (wasSelected !== isSelected) onSelect(host);
|
||||
}
|
||||
}, [onSelect, onSelectionChange, selectableHosts, selectedHostIds]);
|
||||
|
||||
const handleHostClick = useCallback((host: Host) => {
|
||||
if (multiSelect && onSelectionChange) {
|
||||
onSelectionChange(toggleIdsInSelection(selectedHostIds, [host.id]));
|
||||
return;
|
||||
}
|
||||
onSelect(host);
|
||||
}, [multiSelect, onSelect, onSelectionChange, selectedHostIds]);
|
||||
|
||||
const handleGroupToggle = useCallback((groupPath: string) => {
|
||||
const groupHostIds = groupHostIdsByPath.get(groupPath)
|
||||
?? collectSelectableHostIdsInGroup(selectableHosts, groupPath);
|
||||
if (groupHostIds.length === 0) return;
|
||||
applySelectionChange(toggleIdsInSelection(selectedHostIds, groupHostIds));
|
||||
}, [applySelectionChange, groupHostIdsByPath, selectableHosts, selectedHostIds]);
|
||||
|
||||
// Navigable rows (groups + hosts) for listbox keyboard model under virtualization.
|
||||
const navigable = useMemo(() => {
|
||||
const entries: { key: string; listIndex: number; row: SelectHostNavigableRow }[] = [];
|
||||
listRows.forEach((row, listIndex) => {
|
||||
if (row.kind === 'group' || row.kind === 'host') {
|
||||
entries.push({ key: row.key, listIndex, row });
|
||||
}
|
||||
});
|
||||
return entries;
|
||||
}, [listRows]);
|
||||
|
||||
// O(1) key → nav index for virtual row renders (avoid findIndex per visible row).
|
||||
const navigableIndexByKey = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
navigable.forEach((entry, index) => {
|
||||
map.set(entry.key, index);
|
||||
});
|
||||
return map;
|
||||
}, [navigable]);
|
||||
|
||||
const clampedNavIndex = clampListIndex(activeNavIndex, navigable.length);
|
||||
const activeNavEntry = navigable[clampedNavIndex];
|
||||
const activeNavKey = activeNavEntry?.key ?? null;
|
||||
const activeDescendantId = activeNavEntry ? optionDomId(clampedNavIndex) : undefined;
|
||||
|
||||
// Opening a group / changing filters must start at the first entry; otherwise a
|
||||
// deep prior cursor scrolls the new list partway down and hides its top rows.
|
||||
useEffect(() => {
|
||||
setActiveNavIndex(0);
|
||||
}, [currentPath, searchQuery, selectedTags, sortMode]);
|
||||
|
||||
// Inventory-only shrinkage keeps the prior cursor when still in range.
|
||||
useEffect(() => {
|
||||
setActiveNavIndex((prev) => clampListIndex(prev, navigable.length));
|
||||
}, [navigable.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const entry = navigable[clampListIndex(activeNavIndex, navigable.length)];
|
||||
if (!entry) return;
|
||||
listRef.current?.scrollToIndex(entry.listIndex, 'auto');
|
||||
}, [activeNavIndex, navigable]);
|
||||
|
||||
const handleListKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (navigable.length === 0) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActiveNavIndex((prev) => stepListIndex(prev, navigable.length, 1));
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActiveNavIndex((prev) => stepListIndex(prev, navigable.length, -1));
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
setActiveNavIndex(0);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
setActiveNavIndex(Math.max(0, navigable.length - 1));
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
const entry = navigable[clampListIndex(activeNavIndex, navigable.length)];
|
||||
if (!entry) return;
|
||||
if (entry.row.kind === 'group') {
|
||||
if (multiSelect && event.key === ' ') {
|
||||
handleGroupToggle(entry.row.path);
|
||||
return;
|
||||
}
|
||||
setCurrentPath(entry.row.path);
|
||||
return;
|
||||
}
|
||||
if (entry.row.kind === 'host') {
|
||||
handleHostClick(entry.row.host);
|
||||
}
|
||||
}
|
||||
}, [activeNavIndex, handleGroupToggle, handleHostClick, multiSelect, navigable]);
|
||||
|
||||
const renderSelectionIcon = (state: 'none' | 'partial' | 'all') => {
|
||||
if (state === 'all') return <CheckSquare size={16} className="text-primary shrink-0" />;
|
||||
if (state === 'partial') return <MinusSquare size={16} className="text-primary shrink-0" />;
|
||||
return <Square size={16} className="text-muted-foreground shrink-0" />;
|
||||
};
|
||||
|
||||
const getRowHeight = useCallback((row: SelectHostListRow) => (
|
||||
row.kind === 'section' ? SELECT_HOST_SECTION_HEIGHT : SELECT_HOST_ROW_HEIGHT
|
||||
), []);
|
||||
|
||||
const renderRow = useCallback((row: SelectHostListRow) => {
|
||||
if (row.kind === 'section') {
|
||||
return (
|
||||
<div className="flex h-full items-end px-1 pb-1">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">{row.title}</h4>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navIndex = navigableIndexByKey.get(row.key) ?? -1;
|
||||
const isActive = activeNavKey === row.key;
|
||||
|
||||
if (row.kind === 'group') {
|
||||
const groupHostIds = groupHostIdsByPath.get(row.path) ?? [];
|
||||
const groupState = multiSelect
|
||||
? getGroupSelectionState(selectedHostIdSet, groupHostIds)
|
||||
: 'none';
|
||||
const canToggleGroup = multiSelect && groupHostIds.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={navIndex >= 0 ? optionDomId(navIndex) : undefined}
|
||||
role="option"
|
||||
aria-selected={multiSelect ? groupState === 'all' : false}
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (navIndex >= 0) setActiveNavIndex(navIndex);
|
||||
setCurrentPath(row.path);
|
||||
}}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'shrink-0 rounded-sm p-0.5 -m-0.5',
|
||||
canToggleGroup
|
||||
? 'hover:bg-muted cursor-pointer'
|
||||
: 'opacity-40 cursor-not-allowed',
|
||||
)}
|
||||
disabled={!canToggleGroup}
|
||||
aria-label={t('selectHost.toggleGroup', { name: row.name })}
|
||||
aria-pressed={groupState === 'all'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleGroupToggle(row.path);
|
||||
}}
|
||||
>
|
||||
{renderSelectionIcon(groupState)}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2.5 text-left cursor-pointer">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
|
||||
<LayoutGrid size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium truncate">{row.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('vault.groups.hostsCount', { count: row.count })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-muted-foreground shrink-0 opacity-60" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const host = row.host;
|
||||
const isSelected = selectedHostIdSet.has(host.id);
|
||||
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={navIndex >= 0 ? optionDomId(navIndex) : undefined}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
data-host-id={host.id}
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
aria-label={t('selectHost.toggleHost', { name: host.label })}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
|
||||
isSelected ? 'bg-muted' : isActive ? 'bg-primary/10' : 'hover:bg-muted/70',
|
||||
// Keep keyboard cursor visible even when the host is already selected.
|
||||
isActive && 'ring-1 ring-primary/40',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (navIndex >= 0) setActiveNavIndex(navIndex);
|
||||
handleHostClick(host);
|
||||
}}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<span className="shrink-0" aria-hidden>
|
||||
{renderSelectionIcon(isSelected ? 'all' : 'none')}
|
||||
</span>
|
||||
) : null}
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={(host.os || 'L')[0].toUpperCase()}
|
||||
size="md"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[13px] font-medium truncate">{host.label}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{host.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground truncate">{connectionStr}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{connectionStr}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!multiSelect && isSelected ? (
|
||||
<Check size={14} className="text-primary shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
activeNavKey,
|
||||
groupHostIdsByPath,
|
||||
handleGroupToggle,
|
||||
handleHostClick,
|
||||
multiSelect,
|
||||
navigableIndexByKey,
|
||||
optionDomId,
|
||||
selectedHostIdSet,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className={cn('flex flex-col flex-1 min-h-0 min-w-0', className)}>
|
||||
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/60 shrink-0">
|
||||
{(onNewHost || onSaveHost) ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => {
|
||||
if (onSaveHost) {
|
||||
setShowNewHostPanel(true);
|
||||
} else if (onNewHost) {
|
||||
onNewHost();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('selectHost.newHost')}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<TagFilterDropdown
|
||||
allTags={allTags}
|
||||
selectedTags={selectedTags}
|
||||
onChange={setSelectedTags}
|
||||
/>
|
||||
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
{currentPath ? (
|
||||
<div className="flex shrink-0 items-center gap-1 px-3 pt-3 text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPath(null)}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t('vault.hosts.allHosts')}
|
||||
</button>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={crumb.path}>
|
||||
<ChevronRight size={12} className="shrink-0 opacity-50" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPath(crumb.path)}
|
||||
className={cn(
|
||||
'hover:underline',
|
||||
index === breadcrumbs.length - 1
|
||||
? 'text-foreground font-medium'
|
||||
: 'text-primary',
|
||||
)}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{listRows.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>{t('selectHost.noHostsFound')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 min-h-0 px-3 py-2 outline-none"
|
||||
data-host-picker-virtual="select-host"
|
||||
role="listbox"
|
||||
aria-label={t('selectHost.title')}
|
||||
aria-multiselectable={multiSelect || undefined}
|
||||
aria-activedescendant={activeDescendantId}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleListKeyDown}
|
||||
>
|
||||
<VariableSizeVirtualList<SelectHostListRow>
|
||||
ref={listRef}
|
||||
items={listRows}
|
||||
getItemHeight={getRowHeight}
|
||||
className="h-full"
|
||||
overscan={8}
|
||||
getItemKey={(row) => row.key}
|
||||
renderItem={renderRow}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={selectedHostIds.length === 0}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{multiSelect
|
||||
? t('selectHost.continueWithCount', { count: selectedHostIds.length })
|
||||
: t('selectHost.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showNewHostPanel && onSaveHost ? (
|
||||
<HostDetailsPanel
|
||||
initialData={null}
|
||||
availableKeys={availableKeys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groups={customGroups}
|
||||
managedSources={managedSources}
|
||||
allHosts={hosts}
|
||||
onSave={(host) => {
|
||||
onSaveHost(host);
|
||||
setShowNewHostPanel(false);
|
||||
}}
|
||||
onCancel={() => setShowNewHostPanel(false)}
|
||||
onCreateGroup={onCreateGroup}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
479
components/SerialConnectModal.tsx
Normal file
479
components/SerialConnectModal.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Serial Port Connect Modal
|
||||
* Allows users to configure and connect to a serial port
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import { prepareSerialConfigForSavedHost } from '../domain/serialBackspace';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
|
||||
interface SerialPort {
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
type?: 'hardware' | 'pseudo' | 'custom';
|
||||
}
|
||||
|
||||
interface SerialConnectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (config: SerialConfig, options?: { charset?: string }) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
|
||||
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
|
||||
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
|
||||
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
|
||||
|
||||
export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [selectedPort, setSelectedPort] = useState('');
|
||||
const [baudRate, setBaudRate] = useState(115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(1);
|
||||
const [parity, setParity] = useState<SerialParity>('none');
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
const [backspaceBehavior, setBackspaceBehavior] = useState<SerialConfig['backspaceBehavior']>('default');
|
||||
const [charset, setCharset] = useState('UTF-8');
|
||||
|
||||
// Save configuration state
|
||||
const [saveConfig, setSaveConfig] = useState(false);
|
||||
const [configLabel, setConfigLabel] = useState('');
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
try {
|
||||
const result = await terminalBackend.listSerialPorts();
|
||||
setPorts(result);
|
||||
// Auto-select first port if available and no port is selected
|
||||
if (result.length > 0) {
|
||||
setSelectedPort((prev) => prev || result[0].path);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Serial] Failed to list ports:', err);
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, [terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadPorts();
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Generate a default label when port is selected
|
||||
useEffect(() => {
|
||||
if (selectedPort && !configLabel) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
setConfigLabel(`Serial: ${portName}`);
|
||||
}
|
||||
}, [selectedPort, configLabel]);
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
backspaceBehavior,
|
||||
};
|
||||
|
||||
// Save as host if checkbox is checked and onSaveHost is provided
|
||||
if (saveConfig && onSaveHost) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const host: Host = {
|
||||
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: configLabel.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
// For serial hosts, port field stores baud rate as a numeric identifier.
|
||||
// The full configuration is stored in serialConfig for actual connection.
|
||||
port: baudRate,
|
||||
username: '',
|
||||
os: 'linux',
|
||||
tags: ['serial'],
|
||||
protocol: 'serial',
|
||||
createdAt: Date.now(),
|
||||
charset,
|
||||
serialConfig: prepareSerialConfigForSavedHost(config),
|
||||
};
|
||||
onSaveHost(host);
|
||||
}
|
||||
|
||||
onConnect(config, { charset });
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Convert ports to Combobox options
|
||||
const portOptions: ComboboxOption[] = useMemo(() => {
|
||||
return ports.map((port) => ({
|
||||
value: port.path,
|
||||
label: port.path,
|
||||
sublabel: port.manufacturer || undefined,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
// Allow custom baud rates as long as they are positive integers
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Usb size={18} />
|
||||
{t('serial.modal.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('serial.modal.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2 overflow-y-auto flex-1 min-h-0">
|
||||
{/* Serial Port Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPorts}
|
||||
disabled={isLoadingPorts}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<RefreshCw size={12} className={cn("mr-1", isLoadingPorts && "animate-spin")} />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Combobox for port selection with manual input support */}
|
||||
<Combobox
|
||||
options={portOptions}
|
||||
value={selectedPort}
|
||||
onValueChange={setSelectedPort}
|
||||
placeholder={t('serial.field.selectPort')}
|
||||
emptyText={t('serial.noPorts')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-9 px-0 hover:bg-transparent"
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('common.advanced')}
|
||||
</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Select
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serial-backspace">{t('serial.field.backspaceBehavior')}</Label>
|
||||
<Select
|
||||
value={backspaceBehavior}
|
||||
onValueChange={(value) => setBackspaceBehavior(value === 'ctrl-h' ? 'ctrl-h' : 'default')}
|
||||
>
|
||||
<SelectTrigger id="serial-backspace">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t('serial.backspace.default')}</SelectItem>
|
||||
<SelectItem value="ctrl-h">{t('serial.backspace.ctrlH')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.backspaceBehaviorDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.localEcho')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.localEchoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="local-echo"
|
||||
checked={localEcho}
|
||||
onChange={(e) => setLocalEcho(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.lineMode')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.lineModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="line-mode"
|
||||
checked={lineMode}
|
||||
onChange={(e) => setLineMode(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charset */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
||||
{t('serial.field.charset')}
|
||||
</Label>
|
||||
<Input
|
||||
id="serial-charset"
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
value={charset}
|
||||
onChange={(e) => setCharset(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Save Configuration */}
|
||||
{onSaveHost && (
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="save-config" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.saveConfig')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.saveConfigDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-config"
|
||||
checked={saveConfig}
|
||||
onChange={(e) => setSaveConfig(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
{saveConfig && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="config-label"
|
||||
value={configLabel}
|
||||
onChange={(e) => setConfigLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!isValid}>
|
||||
{saveConfig ? (
|
||||
<Save size={14} className="mr-2" />
|
||||
) : (
|
||||
<Cpu size={14} className="mr-2" />
|
||||
)}
|
||||
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialConnectModal;
|
||||
508
components/SerialHostDetailsPanel.tsx
Normal file
508
components/SerialHostDetailsPanel.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Serial Host Details Panel
|
||||
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { GroupConfig, Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import {
|
||||
resolveSerialBackspaceFormValue,
|
||||
resolveSerialBackspaceOverrideOnSave,
|
||||
} from '../domain/serialBackspace';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from './ui/aside-panel';
|
||||
import { HostNotesEditor } from './host/HostNotesEditor';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface SerialPort {
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
type?: 'hardware' | 'pseudo' | 'custom';
|
||||
}
|
||||
|
||||
interface SerialHostDetailsPanelProps {
|
||||
initialData: Host;
|
||||
allTags?: string[];
|
||||
groups?: string[];
|
||||
groupDefaults?: Partial<GroupConfig>;
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SerialHostDetailsPanelPropsWithResize = SerialHostDetailsPanelProps & AsidePanelResizeProps;
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
|
||||
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
|
||||
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
|
||||
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
|
||||
|
||||
export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithResize> = ({
|
||||
initialData,
|
||||
allTags = [],
|
||||
groups = [],
|
||||
groupDefaults,
|
||||
onSave,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
className,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [label, setLabel] = useState(initialData.label);
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
|
||||
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
|
||||
const [parity, setParity] = useState<SerialParity>(initialData.serialConfig?.parity || 'none');
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
||||
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
||||
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
||||
const [backspaceBehavior, setBackspaceBehavior] = useState(
|
||||
resolveSerialBackspaceFormValue(initialData, groupDefaults),
|
||||
);
|
||||
const [backspaceBehaviorChanged, setBackspaceBehaviorChanged] = useState(false);
|
||||
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
const [notes, setNotes] = useState(initialData.notes ?? '');
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
try {
|
||||
const result = await terminalBackend.listSerialPorts();
|
||||
setPorts(result);
|
||||
} catch (err) {
|
||||
console.error('[Serial] Failed to list ports:', err);
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, [terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPorts();
|
||||
}, [loadPorts]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
backspaceBehavior: resolveSerialBackspaceOverrideOnSave({
|
||||
initialHost: initialData,
|
||||
selectedBehavior: backspaceBehavior,
|
||||
behaviorChanged: backspaceBehaviorChanged,
|
||||
}),
|
||||
};
|
||||
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const updatedHost: Host = {
|
||||
...initialData,
|
||||
label: label.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
port: baudRate,
|
||||
tags,
|
||||
group,
|
||||
charset,
|
||||
serialConfig: config,
|
||||
backspaceBehavior: undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
onSave(updatedHost);
|
||||
};
|
||||
|
||||
// Convert ports to Combobox options
|
||||
const portOptions: ComboboxOption[] = useMemo(() => {
|
||||
return ports.map((port) => ({
|
||||
value: port.path,
|
||||
label: port.path,
|
||||
sublabel: port.manufacturer || undefined,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Tag options for MultiCombobox
|
||||
const tagOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allUniqueTags = new Set([...allTags, ...tags]);
|
||||
return Array.from(allUniqueTags).map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
}));
|
||||
}, [allTags, tags]);
|
||||
|
||||
// Group options for Combobox
|
||||
const groupOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allGroups = new Set(groups);
|
||||
if (group && !allGroups.has(group)) {
|
||||
allGroups.add(group);
|
||||
}
|
||||
return Array.from(allGroups).map((g) => ({
|
||||
value: g,
|
||||
label: g,
|
||||
}));
|
||||
}, [groups, group]);
|
||||
|
||||
// Validation
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('serial.edit.title')}
|
||||
subtitle={initialData.label}
|
||||
className={cn('z-40', className)}
|
||||
layout={layout}
|
||||
dataSection="serial-host-details-panel"
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="serial-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Serial Port */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPorts}
|
||||
disabled={isLoadingPorts}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<Combobox
|
||||
options={portOptions}
|
||||
value={selectedPort}
|
||||
onValueChange={setSelectedPort}
|
||||
placeholder={t('serial.field.selectPort')}
|
||||
emptyText={t('serial.noPorts')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Tag size={14} />
|
||||
{t('hostDetails.tags')}
|
||||
</Label>
|
||||
<MultiCombobox
|
||||
options={tagOptions}
|
||||
values={tags}
|
||||
onValuesChange={setTags}
|
||||
placeholder={t('hostDetails.addTag')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createTag')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HostNotesEditor
|
||||
panelKey={initialData.id}
|
||||
value={notes}
|
||||
onChange={setNotes}
|
||||
/>
|
||||
|
||||
{/* Group */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('hostDetails.group')}</Label>
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={group}
|
||||
onValueChange={setGroup}
|
||||
placeholder={t('hostDetails.selectGroup')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createGroup')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-9 px-0 hover:bg-transparent"
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('common.advanced')}
|
||||
</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<Select
|
||||
value={parity}
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<Select
|
||||
value={flowControl}
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serial-backspace">{t('serial.field.backspaceBehavior')}</Label>
|
||||
<Select
|
||||
value={backspaceBehavior}
|
||||
onValueChange={(value) => {
|
||||
setBackspaceBehavior(value === 'ctrl-h' ? 'ctrl-h' : 'default');
|
||||
setBackspaceBehaviorChanged(true);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="serial-backspace">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t('serial.backspace.default')}</SelectItem>
|
||||
<SelectItem value="ctrl-h">{t('serial.backspace.ctrlH')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.backspaceBehaviorDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.localEcho')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.localEchoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="local-echo"
|
||||
checked={localEcho}
|
||||
onChange={(e) => setLocalEcho(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.lineMode')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.lineModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="line-mode"
|
||||
checked={lineMode}
|
||||
onChange={(e) => setLineMode(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charset */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
||||
{t('serial.field.charset')}
|
||||
</Label>
|
||||
<Input
|
||||
id="serial-charset"
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
value={charset}
|
||||
onChange={(e) => setCharset(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</AsidePanelContent>
|
||||
|
||||
<AsidePanelFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
|
||||
<Save size={14} className="mr-2" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialHostDetailsPanel;
|
||||
269
components/SettingsApplicationTab.tsx
Normal file
269
components/SettingsApplicationTab.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowUpCircle, Bug, Check, Github, Loader2, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
|
||||
import AppLogo from "./AppLogo";
|
||||
import AppWordmark from "./AppWordmark";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { SettingsAnchor, SettingsTabContent } from "./settings/settings-ui";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
type AppInfo = {
|
||||
name: string;
|
||||
version: string;
|
||||
platform?: string;
|
||||
};
|
||||
|
||||
const REPO_URL = "https://github.com/binaricat/Netcatty";
|
||||
const BUG_REPORT_TEMPLATE = "bug_report.yml";
|
||||
|
||||
const mapIssuePlatform = (platform?: string) => {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return "macOS";
|
||||
case "win32":
|
||||
return "Windows";
|
||||
case "linux":
|
||||
return "Linux";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */
|
||||
export const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const params = new URLSearchParams({
|
||||
template: BUG_REPORT_TEMPLATE,
|
||||
title: "[Bug] ",
|
||||
});
|
||||
|
||||
if (appInfo.version) {
|
||||
params.set("version", appInfo.version);
|
||||
}
|
||||
|
||||
const platform = mapIssuePlatform(appInfo.platform);
|
||||
if (platform) {
|
||||
params.set("platform", platform);
|
||||
}
|
||||
|
||||
const installSource =
|
||||
appInfo.version === "0.0.0"
|
||||
? "Built from source (npm run dev / pack)"
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)";
|
||||
params.set("install_source", installSource);
|
||||
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
|
||||
params.set(
|
||||
"logs",
|
||||
`Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`,
|
||||
);
|
||||
|
||||
return `${REPO_URL}/issues/new?${params.toString()}`;
|
||||
};
|
||||
|
||||
const ActionRow: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClick: () => void;
|
||||
}> = ({ icon, title, subtitle, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
|
||||
"hover:bg-muted/50 transition-colors"
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 text-muted-foreground">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium leading-tight">{title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
interface SettingsApplicationTabProps {
|
||||
updateState: UpdateState;
|
||||
checkNow: UseUpdateCheckResult['checkNow'];
|
||||
openReleasePage: UseUpdateCheckResult['openReleasePage'];
|
||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||
startDownload: UseUpdateCheckResult['startDownload'];
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const info = await getApplicationInfo();
|
||||
if (!cancelled && info?.name && typeof info?.version === "string") {
|
||||
setAppInfo(info);
|
||||
}
|
||||
} catch {
|
||||
// Ignore: running in browser/dev without Electron bridge
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [getApplicationInfo]);
|
||||
|
||||
const handleOpenExternal = async (url: string) => {
|
||||
try {
|
||||
await openExternal(url);
|
||||
} catch (err) {
|
||||
console.warn("[SettingsApplicationTab] openExternal failed:", err);
|
||||
toast.error(
|
||||
t("settings.application.openExternal.failedBody"),
|
||||
t("settings.application.openExternal.failedTitle"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
// Dev build - just open releases page
|
||||
openReleasePage();
|
||||
return;
|
||||
}
|
||||
|
||||
setLastCheckResult('none');
|
||||
|
||||
const result = await checkNow();
|
||||
|
||||
if (result?.hasUpdate && result.latestRelease) {
|
||||
setLastCheckResult('available');
|
||||
toast.info(
|
||||
t('update.available.message', { version: result.latestRelease.version }),
|
||||
t('update.available.title')
|
||||
);
|
||||
// Don't auto-open the release page here — checkNow() already triggers
|
||||
// electron-updater on supported platforms, and the Settings > System tab
|
||||
// shows a "Manual Download" link on unsupported platforms.
|
||||
} else if (result) {
|
||||
setLastCheckResult('upToDate');
|
||||
toast.success(
|
||||
t('update.upToDate.message', { version: appInfo.version }),
|
||||
t('update.upToDate.title')
|
||||
);
|
||||
}
|
||||
|
||||
// Reset the result after 3 seconds
|
||||
setTimeout(() => setLastCheckResult('none'), 3000);
|
||||
};
|
||||
|
||||
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
|
||||
const releasesUrl = `${REPO_URL}/releases`;
|
||||
const discussionsUrl = `${REPO_URL}/discussions`;
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="application">
|
||||
<div className="flex flex-col lg:flex-row gap-10 lg:gap-14">
|
||||
<div className="lg:w-[320px] shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<AppLogo className="w-16 h-16" />
|
||||
<div>
|
||||
<AppWordmark accessibleLabel="Netcatty" className="h-8 w-auto text-foreground" />
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
</span>
|
||||
{/* Update badge - reflects auto-download state */}
|
||||
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
|
||||
<button
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : updateState.autoDownloadStatus === 'downloading' ? undefined : startDownload()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
updateState.autoDownloadStatus === 'ready'
|
||||
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800"
|
||||
: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800",
|
||||
"transition-colors cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle size={12} />
|
||||
v{updateState.latestRelease.version}{' '}
|
||||
{updateState.autoDownloadStatus === 'ready'
|
||||
? t('update.restartNow')
|
||||
: updateState.autoDownloadStatus === 'downloading'
|
||||
? `${updateState.downloadPercent}%`
|
||||
: t('update.downloadNow')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsAnchor anchorId="application-check-updates" className="mt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
onClick={() => void handleCheckForUpdates()}
|
||||
disabled={updateState.isChecking || updateState.manualCheckStatus === 'checking' || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready'}
|
||||
>
|
||||
{updateState.isChecking ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : lastCheckResult === 'upToDate' ? (
|
||||
<Check size={16} />
|
||||
) : (
|
||||
<RefreshCcw size={16} />
|
||||
)}
|
||||
{updateState.isChecking
|
||||
? t("update.checking")
|
||||
: t("settings.application.checkUpdates")
|
||||
}
|
||||
</Button>
|
||||
</SettingsAnchor>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="space-y-2">
|
||||
<SettingsAnchor anchorId="application-report-problem">
|
||||
<ActionRow
|
||||
icon={<Bug size={18} />}
|
||||
title={t("settings.application.reportProblem")}
|
||||
subtitle={t("settings.application.reportProblem.subtitle")}
|
||||
onClick={() => void handleOpenExternal(issueUrl)}
|
||||
/>
|
||||
</SettingsAnchor>
|
||||
<SettingsAnchor anchorId="application-community">
|
||||
<ActionRow
|
||||
icon={<MessageCircle size={18} />}
|
||||
title={t("settings.application.community")}
|
||||
subtitle={t("settings.application.community.subtitle")}
|
||||
onClick={() => void handleOpenExternal(discussionsUrl)}
|
||||
/>
|
||||
</SettingsAnchor>
|
||||
<SettingsAnchor anchorId="application-github">
|
||||
<ActionRow
|
||||
icon={<Github size={18} />}
|
||||
title="GitHub"
|
||||
subtitle={t("settings.application.github.subtitle")}
|
||||
onClick={() => void handleOpenExternal(REPO_URL)}
|
||||
/>
|
||||
</SettingsAnchor>
|
||||
<SettingsAnchor anchorId="application-whats-new">
|
||||
<ActionRow
|
||||
icon={<Newspaper size={18} />}
|
||||
title={t("settings.application.whatsNew")}
|
||||
subtitle={t("settings.application.whatsNew.subtitle")}
|
||||
onClick={() => void handleOpenExternal(releasesUrl)}
|
||||
/>
|
||||
</SettingsAnchor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
710
components/SettingsPage.tsx
Normal file
710
components/SettingsPage.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAISettingsState } from "../application/state/useAISettingsState";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
||||
import type { AppLockGateRenderContext } from "./AppLockGate";
|
||||
import { toast } from "./ui/toast";
|
||||
import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { SettingsFocusProvider, useSettingsFocus } from "./settings/SettingsFocusContext";
|
||||
import { SettingsSearchControl } from "./settings/SettingsSearchControl";
|
||||
import { cancelSettingsFocus, focusSettingsAnchor } from "./settings/settingsFocus";
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { LazyLoadBoundary } from "./ui/lazy-load-boundary";
|
||||
import { ExternalMcpApprovalsHost } from "./ai/ExternalMcpApprovalsHost";
|
||||
import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister";
|
||||
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
import { PluginContributionHost } from "./plugins/PluginContributionHost";
|
||||
import { matchesKeyBinding } from "../domain/models";
|
||||
import { isPrimaryModifierWBinding } from "../application/state/windowCommandClose";
|
||||
|
||||
const LazySettingsApplicationTab = lazy(() => import("./SettingsApplicationTab"));
|
||||
const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsAppearanceTab"));
|
||||
const LazySettingsFileAssociationsTab = lazy(() => import("./settings/tabs/SettingsFileAssociationsTab"));
|
||||
const LazySettingsShortcutsTab = lazy(() => import("./settings/tabs/SettingsShortcutsTab"));
|
||||
const LazySettingsAITab = lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
const LazySettingsSyncTab = lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab"));
|
||||
const LazySettingsSystemTab = lazy(() => import("./settings/tabs/SettingsSystemTab"));
|
||||
const LazySettingsPluginsTab = lazy(() => import("./settings/tabs/SettingsPluginsTab"));
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
type AITabErrorBoundaryProps = { children: React.ReactNode };
|
||||
type AITabErrorBoundaryState = { error: Error | null };
|
||||
|
||||
class AITabErrorBoundary extends React.Component<
|
||||
AITabErrorBoundaryProps,
|
||||
AITabErrorBoundaryState
|
||||
> {
|
||||
declare props: Readonly<AITabErrorBoundaryProps>;
|
||||
state: { error: Error | null } = { error: null };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ padding: 32, color: "#f87171", fontFamily: "monospace", whiteSpace: "pre-wrap" }}>
|
||||
<h3 style={{ marginBottom: 8 }}>AI Settings Error</h3>
|
||||
<div>{this.state.error.message}</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "#888" }}>{this.state.error.stack}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = AppLockGateRenderContext["settings"];
|
||||
type AppLockState = AppLockGateRenderContext["appLock"];
|
||||
|
||||
const settingsTabTriggerClassName =
|
||||
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
|
||||
const settingsTabIconClassName = "shrink-0";
|
||||
const settingsTabLabelClassName = "min-w-0 truncate";
|
||||
|
||||
const SettingsTabLoading = ({ value }: { value: string }) => (
|
||||
<SettingsTabContent value={value}>
|
||||
<div className="netcatty-lazy-fade-in min-h-[320px]" aria-hidden="true" />
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
const SettingsTabLoadError = ({ value, error }: { value: string; error?: Error }) => (
|
||||
<SettingsTabContent value={value}>
|
||||
<div className="flex min-h-[320px] flex-col items-start justify-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="font-medium text-foreground">This settings tab could not load.</div>
|
||||
{error?.message && (
|
||||
<div className="max-w-md text-xs font-mono text-destructive/90 break-words">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
const SettingsLazyTab = ({ children, value }: { children: React.ReactNode; value: string }) => (
|
||||
<LazyLoadBoundary
|
||||
name="Settings tab"
|
||||
resetKey={value}
|
||||
fallback={(error) => <SettingsTabLoadError value={value} error={error} />}
|
||||
>
|
||||
<Suspense fallback={<SettingsTabLoading value={value} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
|
||||
type TerminalTabSettingsProps = Pick<
|
||||
SettingsState,
|
||||
| 'terminalThemeId'
|
||||
| 'setTerminalThemeId'
|
||||
| 'resolvedTheme'
|
||||
| 'followAppTerminalTheme'
|
||||
| 'setFollowAppTerminalTheme'
|
||||
| 'terminalThemeDarkId'
|
||||
| 'setTerminalThemeDarkId'
|
||||
| 'terminalThemeLightId'
|
||||
| 'setTerminalThemeLightId'
|
||||
| 'lightUiThemeId'
|
||||
| 'darkUiThemeId'
|
||||
| 'terminalFontFamilyId'
|
||||
| 'setTerminalFontFamilyId'
|
||||
| 'terminalFontSize'
|
||||
| 'setTerminalFontSize'
|
||||
| 'terminalSettings'
|
||||
| 'updateTerminalSetting'
|
||||
| 'terminalSidePanelAutoOpen'
|
||||
| 'setTerminalSidePanelAutoOpen'
|
||||
| 'terminalSidePanelAutoOpenTab'
|
||||
| 'setTerminalSidePanelAutoOpenTab'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
>;
|
||||
|
||||
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
resolvedTheme,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
terminalSidePanelAutoOpen,
|
||||
setTerminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
setTerminalSidePanelAutoOpenTab,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
}) {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<LazySettingsTerminalTab
|
||||
terminalThemeId={terminalThemeId}
|
||||
setTerminalThemeId={setTerminalThemeId}
|
||||
resolvedTheme={resolvedTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={setTerminalThemeDarkId}
|
||||
terminalThemeLightId={terminalThemeLightId}
|
||||
setTerminalThemeLightId={setTerminalThemeLightId}
|
||||
lightUiThemeId={lightUiThemeId}
|
||||
darkUiThemeId={darkUiThemeId}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
setTerminalFontSize={setTerminalFontSize}
|
||||
terminalSettings={terminalSettings}
|
||||
updateTerminalSetting={updateTerminalSetting}
|
||||
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
|
||||
setTerminalSidePanelAutoOpen={setTerminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
|
||||
setTerminalSidePanelAutoOpenTab={setTerminalSidePanelAutoOpenTab}
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAISettingsState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
<LazySettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
responseIdleTimeout={aiState.responseIdleTimeout}
|
||||
setResponseIdleTimeout={aiState.setResponseIdleTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
quickMessages={aiState.quickMessages}
|
||||
setQuickMessages={aiState.setQuickMessages}
|
||||
showTerminalSelectionAIAction={aiState.showTerminalSelectionAIAction}
|
||||
setShowTerminalSelectionAIAction={aiState.setShowTerminalSelectionAIAction}
|
||||
/>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState();
|
||||
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
// Strip transient runtime fields before passing to sync
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [],
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
const vault = useMemo(
|
||||
() => ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
}),
|
||||
[
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<LazySettingsSyncTab
|
||||
vault={vault}
|
||||
portForwardingRules={portForwardingRulesForSync}
|
||||
importDataFromString={importDataFromString}
|
||||
importPortForwardingRules={importPortForwardingRules}
|
||||
clearVaultData={clearVaultData}
|
||||
onSettingsApplied={onSettingsApplied}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
|
||||
const { t } = useI18n();
|
||||
const { request, clearFocus, openSearch } = useSettingsFocus();
|
||||
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({
|
||||
autoUpdateEnabled: settings.autoUpdateEnabled,
|
||||
// Install blocked by unsaved editors in the main window — surface a toast
|
||||
// here so a click from the Settings window isn't a silent no-op (#1215).
|
||||
onNeedsSave: () => toast.warning(t('update.needsSave.message'), t('update.needsSave.title')),
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
const { available: pluginRuntimeAvailable } = usePluginContributions();
|
||||
const closeTabKeyStr = useMemo(() => {
|
||||
if (settings.hotkeyScheme === "disabled") return null;
|
||||
const binding = settings.keyBindings.find((item) => item.action === "closeTab");
|
||||
if (!binding) return null;
|
||||
return settings.hotkeyScheme === "mac" ? binding.mac : binding.pc;
|
||||
}, [settings.hotkeyScheme, settings.keyBindings]);
|
||||
const nativeCommandWClosesSettings = isPrimaryModifierWBinding(closeTabKeyStr, matchesKeyBinding, true);
|
||||
|
||||
useEffect(() => {
|
||||
notifyRendererReady();
|
||||
}, [notifyRendererReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const isFind = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f";
|
||||
if (!isFind) return;
|
||||
event.preventDefault();
|
||||
openSearch();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [openSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
|
||||
useExternalMcpGrantPersister();
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onWindowCommandCloseRequested(() => {
|
||||
if (!nativeCommandWClosesSettings) {
|
||||
(document.activeElement ?? window).dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "w",
|
||||
code: "KeyW",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
void closeSettingsWindow();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [closeSettingsWindow, nativeCommandWClosesSettings, onWindowCommandCloseRequested]);
|
||||
|
||||
useEffect(() => {
|
||||
setMountedTabs((prev) => {
|
||||
if (prev.has(activeTab)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(activeTab);
|
||||
return next;
|
||||
});
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
if (activeTab !== request.tab) {
|
||||
setActiveTab(request.tab);
|
||||
return;
|
||||
}
|
||||
// Nested tabs (AI / Sync) read `request` to switch sub-tabs. Keep it until
|
||||
// scroll succeeds (or retries are exhausted) so lazy mounts still see it.
|
||||
let cancelled = false;
|
||||
const nestedDelayMs = (request.aiSubTab || request.syncSubTab) ? 80 : 40;
|
||||
const focusHandle = window.setTimeout(() => {
|
||||
void focusSettingsAnchor(request.anchorId, {
|
||||
attempts: 48,
|
||||
delayMs: 50,
|
||||
}).finally(() => {
|
||||
if (!cancelled) clearFocus();
|
||||
});
|
||||
}, nestedDelayMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(focusHandle);
|
||||
cancelSettingsFocus();
|
||||
};
|
||||
}, [request, activeTab, clearFocus]);
|
||||
|
||||
const handleTabChange = useCallback((tab: string) => {
|
||||
// Manual sidebar navigation should cancel any pending search jump.
|
||||
cancelSettingsFocus();
|
||||
clearFocus();
|
||||
setActiveTab(tab);
|
||||
}, [clearFocus]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeSettingsWindow();
|
||||
}, [closeSettingsWindow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-window h-screen flex flex-col bg-background text-foreground font-sans">
|
||||
<div className="shrink-0 border-b border-border app-drag">
|
||||
<div className="flex items-center justify-between px-4 pt-3">
|
||||
{isMac && <div className="h-6" />}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<h1 className="text-lg font-semibold">{t("settings.title")}</h1>
|
||||
{!isMac && (
|
||||
// No tooltip: on Windows the primary-colored tooltip sits in a
|
||||
// drag titlebar region, can stick open, and blocks the real X.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label={t("common.close")}
|
||||
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
orientation="vertical"
|
||||
className="flex-1 flex overflow-hidden"
|
||||
>
|
||||
<div className="w-56 border-r border-border flex flex-col shrink-0 px-3 py-3">
|
||||
<SettingsSearchControl includePlugins={pluginRuntimeAvailable} />
|
||||
<TabsList className="flex flex-col h-auto bg-transparent gap-1 p-0 justify-start">
|
||||
<TabsTrigger
|
||||
value="application"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<AppWindow size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.application")}</span>
|
||||
</TabsTrigger>
|
||||
{pluginRuntimeAvailable && (
|
||||
<TabsTrigger value="plugins" className={settingsTabTriggerClassName}>
|
||||
<Puzzle size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.plugins")}</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="appearance"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Palette size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.appearance")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<TerminalSquare size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.terminal")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="shortcuts"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Keyboard size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.shortcuts")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="file-associations"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<FileType size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.sftpFileAssociations")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="ai"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Sparkles size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.ai")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sync"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Cloud size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.syncCloud")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="system"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<HardDrive size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.system")}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
|
||||
{mountedTabs.has("application") && (
|
||||
<SettingsLazyTab value="application">
|
||||
<LazySettingsApplicationTab
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
startDownload={startDownload}
|
||||
isUpdateDemoMode={isUpdateDemoMode}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("appearance") && (
|
||||
<SettingsLazyTab value="appearance">
|
||||
<LazySettingsAppearanceTab
|
||||
theme={settings.theme}
|
||||
resolvedTheme={settings.resolvedTheme}
|
||||
setTheme={settings.setTheme}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
setLightUiThemeId={settings.setLightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
setDarkUiThemeId={settings.setDarkUiThemeId}
|
||||
accentMode={settings.accentMode}
|
||||
setAccentMode={settings.setAccentMode}
|
||||
customAccent={settings.customAccent}
|
||||
setCustomAccent={settings.setCustomAccent}
|
||||
uiFontFamilyId={settings.uiFontFamilyId}
|
||||
setUiFontFamilyId={settings.setUiFontFamilyId}
|
||||
uiLanguage={settings.uiLanguage}
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
setShowRecentHosts={settings.setShowRecentHosts}
|
||||
hostClickBehavior={settings.hostClickBehavior}
|
||||
setHostClickBehavior={settings.setHostClickBehavior}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
appIconVariant={settings.appIconVariant}
|
||||
setAppIconVariant={settings.setAppIconVariant}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("terminal") && (
|
||||
<SettingsLazyTab value="terminal">
|
||||
<SettingsTerminalTabContainer
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
resolvedTheme={settings.resolvedTheme}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
|
||||
setTerminalSidePanelAutoOpen={settings.setTerminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
|
||||
setTerminalSidePanelAutoOpenTab={settings.setTerminalSidePanelAutoOpenTab}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("shortcuts") && (
|
||||
<SettingsLazyTab value="shortcuts">
|
||||
<LazySettingsShortcutsTab
|
||||
hotkeyScheme={settings.hotkeyScheme}
|
||||
setHotkeyScheme={settings.setHotkeyScheme}
|
||||
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
|
||||
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
|
||||
showTabNumberBadges={settings.showTabNumberBadges}
|
||||
setShowTabNumberBadges={settings.setShowTabNumberBadges}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
|
||||
keyBindings={settings.keyBindings}
|
||||
updateKeyBinding={settings.updateKeyBinding}
|
||||
resetKeyBinding={settings.resetKeyBinding}
|
||||
resetAllKeyBindings={settings.resetAllKeyBindings}
|
||||
setIsHotkeyRecording={settings.setIsHotkeyRecording}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("file-associations") && (
|
||||
<SettingsLazyTab value="file-associations">
|
||||
<LazySettingsFileAssociationsTab />
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("ai") && (
|
||||
<SettingsLazyTab value="ai">
|
||||
<SettingsAITabContainer />
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<SettingsLazyTab value="sync">
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && (
|
||||
<SettingsLazyTab value="system">
|
||||
<LazySettingsSystemTab
|
||||
appLockSettings={settings.appLockSettings}
|
||||
setAppLockTimeoutMinutes={settings.setAppLockTimeoutMinutes}
|
||||
requestAppLockDisable={settings.requestAppLockDisable}
|
||||
requestAppLockPasswordChange={settings.requestAppLockPasswordChange}
|
||||
appLockSystemUnlockStatus={appLock?.systemUnlockStatus}
|
||||
setAppLockSystemUnlockEnabled={settings.setAppLockSystemUnlockEnabled}
|
||||
sessionLogsEnabled={settings.sessionLogsEnabled}
|
||||
setSessionLogsEnabled={settings.setSessionLogsEnabled}
|
||||
sessionLogsDir={settings.sessionLogsDir}
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={settings.sessionLogsTimestampsEnabled}
|
||||
setSessionLogsTimestampsEnabled={settings.setSessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={settings.sshDebugLogsEnabled}
|
||||
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
|
||||
sshDeepLinkEnabled={settings.sshDeepLinkEnabled}
|
||||
setSshDeepLinkEnabled={settings.setSshDeepLinkEnabled}
|
||||
jmsDeepLinkEnabled={settings.jmsDeepLinkEnabled}
|
||||
setJmsDeepLinkEnabled={settings.setJmsDeepLinkEnabled}
|
||||
explorerContextMenuEnabled={settings.explorerContextMenuEnabled}
|
||||
setExplorerContextMenuEnabled={settings.setExplorerContextMenuEnabled}
|
||||
explorerContextMenuSupported={settings.explorerContextMenuSupported}
|
||||
restorePreviousSession={settings.restorePreviousSession}
|
||||
setRestorePreviousSession={settings.setRestorePreviousSession}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
setRestoreTerminalCwd={settings.setRestoreTerminalCwd}
|
||||
startupLanding={settings.startupLanding}
|
||||
setStartupLanding={settings.setStartupLanding}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
autoLaunchEnabled={settings.autoLaunchEnabled}
|
||||
setAutoLaunchEnabled={settings.setAutoLaunchEnabled}
|
||||
autoLaunchSupported={settings.autoLaunchSupported}
|
||||
httpNetworkProxy={settings.httpNetworkProxy}
|
||||
setHttpNetworkProxy={settings.setHttpNetworkProxy}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
globalHotkeyEnabled={settings.globalHotkeyEnabled}
|
||||
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
|
||||
autoUpdateEnabled={settings.autoUpdateEnabled}
|
||||
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
openReleasePage={openReleasePage}
|
||||
startDownload={startDownload}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
{mountedTabs.has("plugins") && pluginRuntimeAvailable && (
|
||||
<SettingsLazyTab value="plugins">
|
||||
<LazySettingsPluginsTab />
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SettingsPage({
|
||||
settings,
|
||||
appLock,
|
||||
}: {
|
||||
settings: SettingsState;
|
||||
appLock: AppLockState;
|
||||
}) {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<SettingsFocusProvider>
|
||||
<SettingsPageContent settings={settings} appLock={appLock} />
|
||||
</SettingsFocusProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
254
components/SftpClipboardUpload.test.ts
Normal file
254
components/SftpClipboardUpload.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
confirmSftpClipboardUpload,
|
||||
createDropEntriesFromClipboardFiles,
|
||||
getSftpClipboardSystemTextPaths,
|
||||
getSupportedClipboardUploadFiles,
|
||||
isSftpNativeClipboardPasteEnabled,
|
||||
resolveSftpClipboardUploadTarget,
|
||||
shouldLetNativePasteEventHandleSftpPaste,
|
||||
shouldStartClipboardUploadConfirm,
|
||||
type ClipboardLocalFile,
|
||||
type SftpClipboardUploadRequest,
|
||||
} from "./sftp/clipboardUpload.ts";
|
||||
import type { SftpFileEntry } from "../types";
|
||||
|
||||
const file = (name: string, overrides: Partial<SftpFileEntry> = {}): SftpFileEntry => ({
|
||||
name,
|
||||
type: "file",
|
||||
size: 1,
|
||||
modified: new Date(0),
|
||||
permissions: "-rw-r--r--",
|
||||
owner: "",
|
||||
group: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("clipboard upload targets the selected folder in the file list", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["logs"],
|
||||
files: [file("logs", { type: "directory" })],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app/logs");
|
||||
});
|
||||
|
||||
test("clipboard upload targets the current directory without a concrete folder selection", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: [],
|
||||
files: [file("logs", { type: "directory" })],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app");
|
||||
});
|
||||
|
||||
test("clipboard upload ignores selected regular files when resolving the target", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["readme.md"],
|
||||
files: [file("readme.md")],
|
||||
treeSelection: [],
|
||||
});
|
||||
|
||||
assert.equal(target, "/home/app");
|
||||
});
|
||||
|
||||
test("clipboard upload targets the selected folder in the tree", () => {
|
||||
const target = resolveSftpClipboardUploadTarget({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: [],
|
||||
files: [],
|
||||
treeSelection: [{ name: "logs", path: "/var/logs", isDirectory: true }],
|
||||
});
|
||||
|
||||
assert.equal(target, "/var/logs");
|
||||
});
|
||||
|
||||
test("SFTP clipboard system text uses selected list paths", () => {
|
||||
assert.deepEqual(
|
||||
getSftpClipboardSystemTextPaths({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["one.txt", "nested two.txt"],
|
||||
treeSelection: [],
|
||||
}),
|
||||
["/home/app/one.txt", "/home/app/nested two.txt"],
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP clipboard system text uses selected tree paths", () => {
|
||||
assert.deepEqual(
|
||||
getSftpClipboardSystemTextPaths({
|
||||
currentPath: "/home/app",
|
||||
selectedFileNames: ["ignored.txt"],
|
||||
treeSelection: [
|
||||
{ name: "logs", path: "/var/logs", isDirectory: true },
|
||||
{ name: "report.txt", path: "/var/report.txt", isDirectory: false },
|
||||
],
|
||||
}),
|
||||
["/var/logs", "/var/report.txt"],
|
||||
);
|
||||
});
|
||||
|
||||
test("clipboard files become path-backed upload entries", () => {
|
||||
const files: ClipboardLocalFile[] = [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
];
|
||||
|
||||
assert.deepEqual(createDropEntriesFromClipboardFiles(files), [
|
||||
{
|
||||
file: null,
|
||||
localPath: "/Users/me/Desktop/report.txt",
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
size: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("clipboard upload keeps directories for recursive folder paste", () => {
|
||||
const files: ClipboardLocalFile[] = [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
|
||||
];
|
||||
|
||||
assert.deepEqual(getSupportedClipboardUploadFiles(files), files);
|
||||
});
|
||||
|
||||
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Ctrl + V"), true);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "⌘ + V"), true);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Ctrl + Shift + V"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "Cmd + Shift + V"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpPaste", "F9"), false);
|
||||
assert.equal(shouldLetNativePasteEventHandleSftpPaste("sftpCopy", "Ctrl + V"), false);
|
||||
});
|
||||
|
||||
test("clipboard upload confirmation clears the dialog before awaiting transfer", async () => {
|
||||
const events: string[] = [];
|
||||
let resolveUpload: (() => void) | undefined;
|
||||
const uploadDone = new Promise<void>((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
});
|
||||
|
||||
const request: SftpClipboardUploadRequest = {
|
||||
scopeId: "pane-1",
|
||||
side: "left",
|
||||
targetPath: "/remote/inbox",
|
||||
files: [{ path: "/tmp/a.txt", name: "a.txt", isDirectory: false, size: 1 }],
|
||||
onConfirm: async () => {
|
||||
events.push("upload-start");
|
||||
await uploadDone;
|
||||
events.push("upload-end");
|
||||
},
|
||||
};
|
||||
|
||||
const confirmPromise = confirmSftpClipboardUpload({
|
||||
request,
|
||||
clear: () => {
|
||||
events.push("clear");
|
||||
},
|
||||
onUploaded: (targetPath) => {
|
||||
events.push(`uploaded:${targetPath}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Dialog must already be cleared while the transfer is still in flight.
|
||||
// async function runs to its first await before returning the promise.
|
||||
assert.deepEqual(events, ["clear", "upload-start"]);
|
||||
|
||||
resolveUpload?.();
|
||||
await confirmPromise;
|
||||
assert.deepEqual(events, ["clear", "upload-start", "upload-end", "uploaded:/remote/inbox"]);
|
||||
});
|
||||
|
||||
test("clipboard upload confirmation skips onUploaded when transfer fails", async () => {
|
||||
const events: string[] = [];
|
||||
const request: SftpClipboardUploadRequest = {
|
||||
scopeId: "pane-1",
|
||||
side: "left",
|
||||
targetPath: "/remote/inbox",
|
||||
files: [{ path: "/tmp/a.txt", name: "a.txt", isDirectory: false, size: 1 }],
|
||||
onConfirm: async () => {
|
||||
events.push("upload-start");
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => confirmSftpClipboardUpload({
|
||||
request,
|
||||
clear: () => {
|
||||
events.push("clear");
|
||||
},
|
||||
onUploaded: () => {
|
||||
events.push("uploaded");
|
||||
},
|
||||
}),
|
||||
/boom/,
|
||||
);
|
||||
|
||||
// Dialog is still cleared so the UI is not stuck; refresh callback is skipped.
|
||||
assert.deepEqual(events, ["clear", "upload-start"]);
|
||||
});
|
||||
|
||||
test("clipboard upload confirm guard is per request, not a global lock", () => {
|
||||
const first: SftpClipboardUploadRequest = {
|
||||
scopeId: "pane-1",
|
||||
side: "left",
|
||||
targetPath: "/a",
|
||||
files: [{ path: "/tmp/a.txt", name: "a.txt", isDirectory: false, size: 1 }],
|
||||
onConfirm: async () => {},
|
||||
};
|
||||
const second: SftpClipboardUploadRequest = {
|
||||
scopeId: "pane-1",
|
||||
side: "left",
|
||||
targetPath: "/b",
|
||||
files: [{ path: "/tmp/b.txt", name: "b.txt", isDirectory: false, size: 1 }],
|
||||
onConfirm: async () => {},
|
||||
};
|
||||
|
||||
assert.equal(shouldStartClipboardUploadConfirm(first, null), true);
|
||||
assert.equal(shouldStartClipboardUploadConfirm(first, first), false);
|
||||
// A later paste while the first transfer is still running must remain confirmable.
|
||||
assert.equal(shouldStartClipboardUploadConfirm(second, first), true);
|
||||
assert.equal(shouldStartClipboardUploadConfirm(null, first), false);
|
||||
});
|
||||
|
||||
test("native clipboard paste follows SFTP paste shortcut availability", () => {
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("disabled", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + V", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Disabled", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "F9", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + Shift + V", category: "sftp" },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSftpNativeClipboardPasteEnabled("pc", [
|
||||
{ id: "sftp-paste", action: "sftpPaste", label: "Paste", mac: "⌘ + V", pc: "Ctrl + V", category: "sftp" },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
72
components/SftpPaneFileList.test.tsx
Normal file
72
components/SftpPaneFileList.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { SftpFileEntry } from "../types.ts";
|
||||
import {
|
||||
getSftpListUploadFilesTargetPath,
|
||||
getSftpTreeUploadFilesTargetPath,
|
||||
getSftpUploadFilesLabelKey,
|
||||
getSftpUploadFolderLabelKey,
|
||||
shouldShowSftpUploadFolderMenu,
|
||||
shouldShowSftpUploadFilesMenu,
|
||||
} from "./sftp/sftpUploadMenu.ts";
|
||||
|
||||
const baseEntry: SftpFileEntry = {
|
||||
name: "notes.txt",
|
||||
type: "file",
|
||||
size: 1,
|
||||
sizeFormatted: "1 B",
|
||||
lastModified: 1,
|
||||
lastModifiedFormatted: "now",
|
||||
};
|
||||
|
||||
test("upload file menu is shown only for remote panes with a picker upload handler", () => {
|
||||
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: true }), true);
|
||||
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: true, hasFileListUpload: true }), false);
|
||||
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: false }), false);
|
||||
});
|
||||
|
||||
test("upload folder menu is shown only for remote panes with a folder upload handler", () => {
|
||||
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: true }), true);
|
||||
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: true, hasFolderUpload: true }), false);
|
||||
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: false }), false);
|
||||
});
|
||||
|
||||
test("directory row upload targets that directory without using its name in the label", () => {
|
||||
const directoryEntry: SftpFileEntry = {
|
||||
...baseEntry,
|
||||
name: "a-very-long-folder-name-that-should-not-expand-the-context-menu",
|
||||
type: "directory",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
getSftpListUploadFilesTargetPath(directoryEntry, "/home/app"),
|
||||
"/home/app/a-very-long-folder-name-that-should-not-expand-the-context-menu",
|
||||
);
|
||||
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
|
||||
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
|
||||
});
|
||||
|
||||
test("file row upload targets the current directory", () => {
|
||||
assert.equal(getSftpListUploadFilesTargetPath(baseEntry, "/home/app"), undefined);
|
||||
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
|
||||
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
|
||||
});
|
||||
|
||||
test("tree directory row upload targets that directory", () => {
|
||||
const directoryEntry: SftpFileEntry = {
|
||||
...baseEntry,
|
||||
name: "logs",
|
||||
type: "directory",
|
||||
};
|
||||
|
||||
assert.equal(getSftpTreeUploadFilesTargetPath(directoryEntry, "/var/logs"), "/var/logs");
|
||||
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
|
||||
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
|
||||
});
|
||||
|
||||
test("tree file row upload targets the file parent directory", () => {
|
||||
assert.equal(getSftpTreeUploadFilesTargetPath(baseEntry, "/var/logs/app.log"), "/var/logs");
|
||||
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
|
||||
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
|
||||
});
|
||||
1997
components/SftpSidePanel.tsx
Normal file
1997
components/SftpSidePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
38
components/SftpSidePanelDeferredMount.tsx
Normal file
38
components/SftpSidePanelDeferredMount.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { startTransition, useEffect } from 'react';
|
||||
|
||||
type SftpSidePanelDeferredMountProps = {
|
||||
children: React.ReactNode;
|
||||
ready: boolean;
|
||||
onReady: () => void;
|
||||
};
|
||||
|
||||
export const SftpSidePanelDeferredMount: React.FC<SftpSidePanelDeferredMountProps> = ({
|
||||
children,
|
||||
ready,
|
||||
onReady,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (ready) return;
|
||||
|
||||
let cancelled = false;
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
startTransition(() => onReady());
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [ready, onReady]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex h-full items-center justify-center bg-background text-xs text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
290
components/SftpTransferItem.test.tsx
Normal file
290
components/SftpTransferItem.test.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { TransferTask } from "../types.ts";
|
||||
import { SftpTransferItem } from "./sftp/SftpTransferItem.tsx";
|
||||
|
||||
const baseTask: TransferTask = {
|
||||
id: "transfer-1",
|
||||
fileName: "archive.tar.gz",
|
||||
sourcePath: "/local/archive.tar.gz",
|
||||
targetPath: "/remote/archive.tar.gz",
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload",
|
||||
status: "failed",
|
||||
totalBytes: 1024,
|
||||
transferredBytes: 512,
|
||||
speed: 0,
|
||||
error: "Network error",
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
};
|
||||
|
||||
const renderTransferItem = (
|
||||
task: TransferTask,
|
||||
props: Partial<React.ComponentProps<typeof SftpTransferItem>> = {},
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(SftpTransferItem, {
|
||||
task,
|
||||
onCancel: () => {},
|
||||
onPause: () => {},
|
||||
onResume: () => {},
|
||||
onRetry: () => {},
|
||||
onDismiss: () => {},
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("renders failed transfer actions with custom tooltips and readable labels", () => {
|
||||
const markup = renderTransferItem(baseTask);
|
||||
|
||||
assert.match(markup, /aria-label="Retry: archive\.tar\.gz"/);
|
||||
assert.match(markup, /aria-label="Dismiss: archive\.tar\.gz"/);
|
||||
assert.match(markup, /focus-visible:ring-1/);
|
||||
});
|
||||
|
||||
test("renders active transfer cancel action with an item-specific label", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
speed: 128,
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
|
||||
assert.match(markup, /aria-label="Pause: archive\.tar\.gz"/);
|
||||
});
|
||||
|
||||
test("directory parents show transfer-center done/found progress instead of Waiting", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
id: "folder-1",
|
||||
fileName: "big-tree",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
status: "pending",
|
||||
phase: "transferring",
|
||||
transferredBytes: 65,
|
||||
totalBytes: 108202,
|
||||
error: undefined,
|
||||
speed: 0,
|
||||
});
|
||||
|
||||
assert.match(markup, /65 done · 108202 found/);
|
||||
assert.doesNotMatch(markup, />Waiting\.\.\.</);
|
||||
});
|
||||
|
||||
test("scanning directory parents keep scanning label with live discovery counts", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
id: "folder-scan",
|
||||
fileName: "docs",
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
status: "pending",
|
||||
phase: "scanning",
|
||||
transferredBytes: 0,
|
||||
totalBytes: 1284,
|
||||
error: undefined,
|
||||
speed: 0,
|
||||
});
|
||||
|
||||
assert.match(markup, /Scanning/);
|
||||
assert.match(markup, /0 done · 1284 found/);
|
||||
});
|
||||
|
||||
test("child rows reserve an action column wide enough for pause and cancel", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "child-transfer-actions",
|
||||
parentTaskId: "transfer-1",
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
transferredBytes: 256,
|
||||
totalBytes: 1024,
|
||||
speed: 128,
|
||||
resumable: true,
|
||||
},
|
||||
{
|
||||
isChild: true,
|
||||
childNameColumnWidth: 260,
|
||||
onResizeNameColumn: () => {},
|
||||
onSetNameColumnWidth: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /grid-template-columns:24px 260px 10px minmax\(0, 1fr\) 56px/);
|
||||
assert.match(markup, /aria-label="Pause: archive\.tar\.gz"/);
|
||||
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
|
||||
});
|
||||
|
||||
test("renders paused transfer resume action", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
status: "paused",
|
||||
error: undefined,
|
||||
resumable: true,
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Resume: archive\.tar\.gz"/);
|
||||
// Paused rows keep the checkpoint progress bar (amber, no shimmer).
|
||||
assert.match(markup, /bg-amber-500\/80/);
|
||||
assert.doesNotMatch(markup, /progress-shimmer/);
|
||||
});
|
||||
|
||||
test("renders resume spinner while reconnecting", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
status: "pending",
|
||||
error: undefined,
|
||||
reconnectRequired: true,
|
||||
resumable: true,
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Reconnecting and resuming…: archive\.tar\.gz"/);
|
||||
assert.match(markup, /aria-busy="true"/);
|
||||
assert.doesNotMatch(markup, /aria-label="Resume: archive\.tar\.gz"/);
|
||||
assert.match(markup, /animate-spin/);
|
||||
});
|
||||
|
||||
test("renders pausing state feedback instead of a dead pause button", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
status: "pausing",
|
||||
error: undefined,
|
||||
speed: 0,
|
||||
resumable: true,
|
||||
});
|
||||
|
||||
// Soft-drain is short; status copy is "Pausing" (not "finishing current step").
|
||||
assert.match(markup, /aria-label="Pausing: archive\.tar\.gz"/);
|
||||
assert.match(markup, /aria-busy="true"/);
|
||||
assert.doesNotMatch(markup, /aria-label="Pause: archive\.tar\.gz"/);
|
||||
assert.match(markup, />Pausing</);
|
||||
});
|
||||
|
||||
test("surfaces pause unavailable reason on a transferring row", () => {
|
||||
const markup = renderTransferItem({
|
||||
...baseTask,
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
speed: 128,
|
||||
pauseUnavailableReason: "This transfer cannot be paused yet",
|
||||
});
|
||||
|
||||
assert.match(markup, /This transfer cannot be paused yet/);
|
||||
});
|
||||
|
||||
test("renders child resize handle as a keyboard-reachable separator", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "child-transfer-1",
|
||||
parentTaskId: "transfer-1",
|
||||
status: "transferring",
|
||||
error: undefined,
|
||||
transferredBytes: 256,
|
||||
speed: 128,
|
||||
},
|
||||
{
|
||||
isChild: true,
|
||||
childNameColumnWidth: 260,
|
||||
onResizeNameColumn: () => {},
|
||||
onSetNameColumnWidth: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /role="separator"/);
|
||||
assert.match(markup, /aria-label="Resize file name column"/);
|
||||
assert.match(markup, /aria-orientation="vertical"/);
|
||||
assert.match(markup, /tabindex="0"/);
|
||||
});
|
||||
|
||||
test("can remove duplicate child resize handles from the tab order", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "child-transfer-2",
|
||||
parentTaskId: "transfer-1",
|
||||
status: "pending",
|
||||
error: undefined,
|
||||
},
|
||||
{
|
||||
isChild: true,
|
||||
onResizeNameColumn: () => {},
|
||||
onSetNameColumnWidth: () => {},
|
||||
resizeHandleTabIndex: -1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /role="separator"/);
|
||||
assert.match(markup, /tabindex="-1"/);
|
||||
});
|
||||
|
||||
test("keeps reveal target and child toggle as separate buttons", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
status: "completed",
|
||||
error: undefined,
|
||||
isDirectory: true,
|
||||
},
|
||||
{
|
||||
canRevealTarget: true,
|
||||
onRevealTarget: () => {},
|
||||
canToggleChildren: true,
|
||||
isExpanded: false,
|
||||
childListId: "children-transfer-1",
|
||||
onToggleChildren: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const revealStart = markup.indexOf('<button type="button" class="flex min-w-0 flex-1');
|
||||
assert.notEqual(revealStart, -1);
|
||||
|
||||
const revealEnd = markup.indexOf("</button>", revealStart);
|
||||
const toggleStart = markup.indexOf('aria-label="Show detail"');
|
||||
|
||||
assert.notEqual(toggleStart, -1);
|
||||
assert.ok(toggleStart > revealEnd);
|
||||
assert.match(markup, /aria-expanded="false"/);
|
||||
assert.match(markup, /aria-controls="children-transfer-1"/);
|
||||
});
|
||||
|
||||
test("renders explicit target actions for completed local downloads", () => {
|
||||
const markup = renderTransferItem(
|
||||
{
|
||||
...baseTask,
|
||||
id: "download-1",
|
||||
fileName: "report.pdf",
|
||||
sourcePath: "/remote/report.pdf",
|
||||
targetPath: "/Users/alice/Downloads/report.pdf",
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "completed",
|
||||
error: undefined,
|
||||
transferredBytes: 1024,
|
||||
},
|
||||
{
|
||||
canRevealTarget: true,
|
||||
onRevealTarget: () => {},
|
||||
canCopyTargetPath: true,
|
||||
onCopyTargetPath: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Open target folder: report\.pdf"/);
|
||||
assert.match(markup, /aria-label="Copy target path: report\.pdf"/);
|
||||
assert.match(markup, /lucide-folder-open/);
|
||||
assert.match(markup, /lucide-clipboard-copy/);
|
||||
});
|
||||
165
components/SftpView.memo.test.tsx
Normal file
165
components/SftpView.memo.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("main SftpView keeps browse sessions across top-tab switches", () => {
|
||||
const source = readFileSync(new URL("./SftpView.tsx", import.meta.url), "utf8");
|
||||
// Mount stays alive after first visit; parking on isActive caused every
|
||||
// remote tab to reconnect when leaving SFTP for Terminal and coming back.
|
||||
assert.match(source, /interactive:\s*true/);
|
||||
assert.doesNotMatch(source, /interactive:\s*isActive/);
|
||||
});
|
||||
|
||||
test("Open SFTP always restores dual-pane layout through the planner", () => {
|
||||
const source = readFileSync(new URL("./SftpView.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /setMagnifiedSide\(null\);\s*applyDualPaneSftpOpen/);
|
||||
assert.match(source, /applyDualPaneSftpOpen/);
|
||||
assert.doesNotMatch(source, /firstVisitBothEmpty/);
|
||||
});
|
||||
|
||||
test("SFTP magnification overlays one side while preserving the original two-pane geometry", async () => {
|
||||
const { resolveTwoPaneMagnificationStyle } = await import("../domain/paneMagnification.ts");
|
||||
|
||||
assert.deepEqual(resolveTwoPaneMagnificationStyle('left', true, false), {
|
||||
left: '0%', top: '0%', width: '50%', height: '100%', zIndex: 10,
|
||||
});
|
||||
assert.deepEqual(resolveTwoPaneMagnificationStyle('right', true, false), {
|
||||
left: '50%', top: '0%', width: '50%', height: '100%', zIndex: 10,
|
||||
});
|
||||
assert.deepEqual(resolveTwoPaneMagnificationStyle('right', true, true), {
|
||||
left: '12px', top: '12px', width: 'calc(100% - 24px)', height: 'calc(100% - 24px)', zIndex: 50,
|
||||
});
|
||||
});
|
||||
|
||||
test("SFTP keyboard focus tracks the active side and blocks the covered sibling", () => {
|
||||
const source = readFileSync(new URL("./SftpView.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /inert=\{magnifiedSide === 'right' \? true : undefined\}[\s\S]*onFocusCapture=\{\(\) => handlePaneFocus\("left"\)\}/);
|
||||
assert.match(source, /inert=\{magnifiedSide === 'left' \? true : undefined\}[\s\S]*onFocusCapture=\{\(\) => handlePaneFocus\("right"\)\}/);
|
||||
});
|
||||
|
||||
test("SftpView re-renders when host-key verification setting changes", async () => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
const { sftpViewAreEqual } = await import("./SftpView.tsx");
|
||||
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
knownHosts: [],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
updateHosts: () => {},
|
||||
onAddKnownHost: () => {},
|
||||
sftpDefaultViewMode: "list",
|
||||
sftpDoubleClickBehavior: "open",
|
||||
sftpAutoSync: false,
|
||||
sftpShowHiddenFiles: false,
|
||||
sftpUseCompressedUpload: false,
|
||||
hotkeyScheme: {},
|
||||
keyBindings: [],
|
||||
editorWordWrap: false,
|
||||
setEditorWordWrap: () => {},
|
||||
terminalSettings: {
|
||||
verifyHostKeys: true,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
sftpViewAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
terminalSettings: {
|
||||
...baseProps.terminalSettings,
|
||||
verifyHostKeys: false,
|
||||
},
|
||||
} as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("SftpView ignores session title-only updates for memoization", async () => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
const { sftpViewAreEqual } = await import("./SftpView.tsx");
|
||||
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
sessions: [{
|
||||
id: "s1",
|
||||
hostId: "h1",
|
||||
hostLabel: "Host",
|
||||
username: "alice",
|
||||
hostname: "h1.example.test",
|
||||
protocol: "ssh",
|
||||
status: "connected",
|
||||
dynamicTitle: "old",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
knownHosts: [],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
updateHosts: () => {},
|
||||
onAddKnownHost: () => {},
|
||||
sftpDefaultViewMode: "list",
|
||||
sftpDoubleClickBehavior: "open",
|
||||
sftpAutoSync: false,
|
||||
sftpShowHiddenFiles: false,
|
||||
sftpUseCompressedUpload: false,
|
||||
hotkeyScheme: {},
|
||||
keyBindings: [],
|
||||
editorWordWrap: false,
|
||||
setEditorWordWrap: () => {},
|
||||
terminalSettings: {
|
||||
verifyHostKeys: true,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
sftpViewAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
sessions: [{
|
||||
...baseProps.sessions[0],
|
||||
dynamicTitle: "new title from OSC",
|
||||
}],
|
||||
} as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
sftpViewAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
sessions: [{
|
||||
...baseProps.sessions[0],
|
||||
status: "connecting",
|
||||
}],
|
||||
} as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
833
components/SftpView.tsx
Normal file
833
components/SftpView.tsx
Normal file
@@ -0,0 +1,833 @@
|
||||
/**
|
||||
* SftpView - SFTP File Browser (Refactored)
|
||||
*
|
||||
* This is the main SFTP view component that provides a dual-pane file browser
|
||||
* for transferring files between local and remote systems.
|
||||
*
|
||||
* Components have been extracted to:
|
||||
* - components/sftp/utils.ts - Utility functions
|
||||
* - components/sftp/SftpBreadcrumb.tsx - Path navigation
|
||||
* - components/sftp/SftpFileRow.tsx - File list row
|
||||
* - components/sftp/SftpTransferItem.tsx - Transfer queue item
|
||||
* - components/sftp/SftpConflictDialog.tsx - Conflict resolution
|
||||
* - components/sftp/SftpPermissionsDialog.tsx - Permissions editor
|
||||
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { activeTabStore as globalActiveTabStore, useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
|
||||
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
||||
import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
|
||||
import {
|
||||
getPaneMagnificationShortcutLabel,
|
||||
resolveTwoPaneMagnificationStyle,
|
||||
type PaneMagnificationController,
|
||||
} from "../domain/paneMagnification";
|
||||
import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
|
||||
import { applyDualPaneSftpOpen, dualPaneTabFromPane } from "../domain/sftpDualPaneOpen";
|
||||
import {
|
||||
consumePendingDualPaneSftpRequest,
|
||||
subscribeDualPaneSftpOpen,
|
||||
} from "../application/state/sftp/sftpDualPaneOpenStore";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// Import extracted components
|
||||
import { SftpTabBar } from "./sftp";
|
||||
import { SftpPaneView, SftpPaneWrapper } from "./sftp/SftpPaneView";
|
||||
import { SftpOverlays } from "./sftp/SftpOverlays";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
// Import context hooks
|
||||
import { SftpContextProvider, activeTabStore } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "../application/state/sftp/sftpFocusStore";
|
||||
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
|
||||
|
||||
// Wrapper component that subscribes to activeTabId for CSS visibility
|
||||
// This isolates the activeTabId subscription - only this component re-renders on tab switch
|
||||
// Uses visibility:hidden pattern from App.tsx for smooth tab switching
|
||||
// Main SftpView component
|
||||
interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
/** Vault-persisted hosts only; used for writes so ephemeral deep-link hosts stay out of vault. */
|
||||
writableHosts?: Host[];
|
||||
sessions?: TerminalSession[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: import('../domain/models').GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
|
||||
paneMagnificationRef?: React.MutableRefObject<PaneMagnificationController | null>;
|
||||
}
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
sessions = [],
|
||||
keys,
|
||||
identities,
|
||||
knownHosts = [],
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
updateHosts,
|
||||
onAddKnownHost,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
terminalSettings,
|
||||
paneMagnificationRef,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paneMagnificationShortcutLabel = getPaneMagnificationShortcutLabel(keyBindings, hotkeyScheme);
|
||||
const isActive = useIsSftpActive();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef("sftp-main-view");
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
|
||||
toast.success(t('sftp.autoSync.success', { fileName }));
|
||||
logger.info("[SFTP] File auto-synced to remote", payload);
|
||||
},
|
||||
onFileWatchError: (payload: { error: string }) => {
|
||||
toast.error(t('sftp.autoSync.error', { error: payload.error }));
|
||||
logger.error("[SFTP] File auto-sync failed", payload);
|
||||
},
|
||||
}), [t]);
|
||||
|
||||
const resolveTransferSourceSessionId = useCallback((hostId: string, host?: Host) => {
|
||||
const hostsById = new Map<string, Host>(hosts.map((h) => [h.id, h]));
|
||||
// Walk all sessions (not the picker one-per-hostId list) so multi-tab
|
||||
// same hostId with different live endpoints can still match.
|
||||
return resolveSftpTransferSourceSessionId(sessions, hostsById, hostId, host);
|
||||
}, [hosts, sessions]);
|
||||
|
||||
const sftpOptions = useMemo(() => ({
|
||||
...fileWatchHandlers,
|
||||
transferOwnerId: "main-sftp-view",
|
||||
// Main SFTP page stays interactive while mounted so top-tab switches
|
||||
// (e.g. Terminal ↔ SFTP) must not soft-close every tab's session.
|
||||
// The terminal side panel parks only after the panel is closed (not when
|
||||
// switching History/System while the chrome stays open).
|
||||
// Bulk transfers use dedicated pool sessions regardless.
|
||||
interactive: true,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
terminalSettings,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
resolveTransferSourceSessionId,
|
||||
}), [
|
||||
fileWatchHandlers,
|
||||
sftpUseCompressedUpload,
|
||||
sftpShowHiddenFiles,
|
||||
terminalSettings,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
resolveTransferSourceSessionId,
|
||||
]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() => {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
return hosts.map(h => {
|
||||
const withGroupDefaults = h.group
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
|
||||
: applyGroupDefaults(h, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
const connectedHosts = useMemo(() => {
|
||||
const hostsById = new Map<string, Host>(
|
||||
effectiveHosts.map((host) => [host.id, host]),
|
||||
);
|
||||
return listSftpConnectedHosts(sessions, hostsById);
|
||||
}, [effectiveHosts, sessions]);
|
||||
|
||||
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get backend helpers for file downloads and local filesystem writes.
|
||||
const {
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
// without needing to re-create when sftp changes
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
const effectiveHostsRef = useRef(effectiveHosts);
|
||||
effectiveHostsRef.current = effectiveHosts;
|
||||
|
||||
useEffect(() => {
|
||||
const toTabs = (panes: Array<{
|
||||
id: string;
|
||||
connection: { id: string; isLocal?: boolean; hostId?: string | null; status?: string | null } | null;
|
||||
}>, getEndpointKey: (connectionId: string) => string | null) => panes.map(
|
||||
(pane) => dualPaneTabFromPane(
|
||||
pane,
|
||||
pane.connection ? getEndpointKey(pane.connection.id) : null,
|
||||
),
|
||||
);
|
||||
|
||||
const applyRequest = (request: { hostId: string } | null) => {
|
||||
if (!request) return;
|
||||
const host = effectiveHostsRef.current.find((candidate) => candidate.id === request.hostId);
|
||||
if (!host) return;
|
||||
const current = sftpRef.current;
|
||||
const hostEndpointKey = buildCacheKey(
|
||||
host.id,
|
||||
host.hostname,
|
||||
host.port,
|
||||
host.protocol,
|
||||
host.sftpSudo,
|
||||
host.username,
|
||||
host.sftpFileProtocol,
|
||||
);
|
||||
// External "Open SFTP" promises a visible local-left / host-right pair.
|
||||
// Clear a stale single-pane magnification before selecting or connecting.
|
||||
setMagnifiedSide(null);
|
||||
applyDualPaneSftpOpen(
|
||||
{
|
||||
leftTabs: toTabs(current.leftTabs.tabs, current.getConnectionCacheKey),
|
||||
rightTabs: toTabs(current.rightTabs.tabs, current.getConnectionCacheKey),
|
||||
selectTab: current.selectTab,
|
||||
connect: (side, nextHost, options) => {
|
||||
void current.connect(side, nextHost === "local" ? "local" : host, options);
|
||||
},
|
||||
},
|
||||
host,
|
||||
hostEndpointKey,
|
||||
);
|
||||
};
|
||||
|
||||
applyRequest(consumePendingDualPaneSftpRequest());
|
||||
return subscribeDualPaneSftpOpen(applyRequest);
|
||||
}, []);
|
||||
|
||||
// Register this useSftpState's writeTextFileByConnection with the bridge so
|
||||
// the editor tab's save path can reach the active SFTP session. The bridge
|
||||
// supports multiple simultaneous writers (SftpSidePanel inside terminals
|
||||
// also registers its own instance) and dispatches by trying each until one
|
||||
// owns the target connectionId.
|
||||
//
|
||||
// Intentionally no deps: `sftp` identity churns on every SFTP state change
|
||||
// (transfers, pane updates, tab switches), which would make this effect
|
||||
// unregister+reregister constantly. Route through sftpRef so the closure
|
||||
// always reads the latest writeTextFileByConnection; that method is stable
|
||||
// across sftp re-renders (it's a methodsRef-backed dispatcher).
|
||||
useEffect(() => {
|
||||
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding, sftpTabId) =>
|
||||
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding, sftpTabId),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Store behavior setting in ref for stable callbacks
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
|
||||
// Store auto-sync setting in ref for stable callbacks
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
|
||||
// SFTP keyboard shortcuts handler
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive,
|
||||
});
|
||||
|
||||
// Subscribe to focused side for visual indicator
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
const [magnifiedSide, setMagnifiedSide] = useState<SftpFocusedSide | null>(null);
|
||||
const focusedSideRef = useRef(focusedSide);
|
||||
const magnifiedSideRef = useRef(magnifiedSide);
|
||||
focusedSideRef.current = focusedSide;
|
||||
magnifiedSideRef.current = magnifiedSide;
|
||||
const [isWideSplit, setIsWideSplit] = useState(true);
|
||||
const [showMagnificationHint, setShowMagnificationHint] = useState(false);
|
||||
const splitSurfaceRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const surface = splitSurfaceRef.current;
|
||||
if (!surface) return undefined;
|
||||
const update = () => setIsWideSplit(surface.clientWidth >= 1024);
|
||||
update();
|
||||
if (typeof ResizeObserver === 'undefined') return undefined;
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(surface);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!magnifiedSide) {
|
||||
setShowMagnificationHint(false);
|
||||
return undefined;
|
||||
}
|
||||
setShowMagnificationHint(true);
|
||||
const timerId = window.setTimeout(() => setShowMagnificationHint(false), 1800);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [magnifiedSide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paneMagnificationRef) return undefined;
|
||||
const controller: PaneMagnificationController = {
|
||||
getState: () => {
|
||||
if (globalActiveTabStore.getActiveTabId() !== 'sftp') return 'unavailable';
|
||||
return magnifiedSideRef.current ? 'focused' : 'focusable';
|
||||
},
|
||||
focus: () => {
|
||||
if (globalActiveTabStore.getActiveTabId() !== 'sftp' || magnifiedSideRef.current) return false;
|
||||
setMagnifiedSide(focusedSideRef.current);
|
||||
return true;
|
||||
},
|
||||
restore: () => {
|
||||
if (globalActiveTabStore.getActiveTabId() !== 'sftp' || !magnifiedSideRef.current) return false;
|
||||
setMagnifiedSide(null);
|
||||
return true;
|
||||
},
|
||||
toggle: () => {
|
||||
if (globalActiveTabStore.getActiveTabId() !== 'sftp') return false;
|
||||
setMagnifiedSide((current) => current ? null : focusedSideRef.current);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
paneMagnificationRef.current = controller;
|
||||
return () => {
|
||||
if (paneMagnificationRef.current === controller) paneMagnificationRef.current = null;
|
||||
};
|
||||
}, [paneMagnificationRef]);
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
// Clear the opposite side's selection so file operations only affect the focused pane
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
|
||||
const prevSide = sftpFocusStore.getFocusedSide();
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
setMagnifiedSide((current) => current ? side : null);
|
||||
if (prevSide !== side) {
|
||||
if (targetTabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
|
||||
} else {
|
||||
// Focus side changed — clear other panes but keep the newly focused pane intact.
|
||||
keepOnlyActivePaneSelections(sftpRef.current, side);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
|
||||
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.id === paneId);
|
||||
if (!pane) return;
|
||||
|
||||
sftpRef.current.setShowHiddenFiles(side, paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
|
||||
// Using useLayoutEffect to sync before paint
|
||||
useLayoutEffect(() => {
|
||||
activeTabStore.setActiveTabId("left", sftp.leftTabs.activeTabId);
|
||||
}, [sftp.leftTabs.activeTabId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
activeTabStore.setActiveTabId("right", sftp.rightTabs.activeTabId);
|
||||
}, [sftp.rightTabs.activeTabId]);
|
||||
|
||||
// 渲染追踪 - 不追踪 activeTabId(现在通过 store 订阅)
|
||||
useRenderTracker("SftpViewInner", {
|
||||
isActive,
|
||||
hostsCount: hosts.length,
|
||||
leftTabsCount: sftp.leftTabs.tabs.length,
|
||||
rightTabsCount: sftp.rightTabs.tabs.length,
|
||||
});
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
|
||||
const getOpenerForFileRef = useRef(getOpenerForFile);
|
||||
getOpenerForFileRef.current = getOpenerForFile;
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
dragCallbacks,
|
||||
draggedFiles,
|
||||
permissionsState,
|
||||
setPermissionsState,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
() => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5),
|
||||
[sftp.transfers],
|
||||
);
|
||||
|
||||
const getTransferTargetDirectory = useCallback(
|
||||
(task: TransferTask) => (task.isDirectory ? task.targetPath : getParentPath(task.targetPath)),
|
||||
[],
|
||||
);
|
||||
|
||||
const findRemoteTransferTargetTab = useCallback((task: TransferTask) => {
|
||||
const state = sftpRef.current;
|
||||
for (const side of ["left", "right"] as const) {
|
||||
const tabs = side === "left" ? state.leftTabs.tabs : state.rightTabs.tabs;
|
||||
const pane = tabs.find((tab) => tab.connection?.id === task.targetConnectionId);
|
||||
if (pane?.connection && !pane.connection.isLocal) {
|
||||
return { side, tabId: pane.id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const canRevealTransferTarget = useCallback(
|
||||
(task: TransferTask) => {
|
||||
if (task.status !== "completed") return false;
|
||||
if (!isConcreteTransferTargetPath(task)) return false;
|
||||
if (task.targetConnectionId === "local") {
|
||||
return true;
|
||||
}
|
||||
return !!findRemoteTransferTargetTab(task);
|
||||
},
|
||||
[findRemoteTransferTargetTab],
|
||||
);
|
||||
|
||||
const handleRevealTransferTarget = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
if (!isConcreteTransferTargetPath(task)) return;
|
||||
const targetDirectory = getTransferTargetDirectory(task);
|
||||
if (task.targetConnectionId === "local") {
|
||||
try {
|
||||
const result = await openPath(targetDirectory);
|
||||
if (result.success) return;
|
||||
} catch {
|
||||
// Show the localized error below.
|
||||
}
|
||||
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTab = findRemoteTransferTargetTab(task);
|
||||
if (!targetTab) return;
|
||||
await sftpRef.current.navigateTo(targetTab.side, targetDirectory, { force: true, tabId: targetTab.tabId });
|
||||
},
|
||||
[findRemoteTransferTargetTab, getTransferTargetDirectory, openPath, t],
|
||||
);
|
||||
|
||||
const canCopyTransferTargetPath = useCallback(
|
||||
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopyTransferTargetPath = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
if (!isConcreteTransferTargetPath(task)) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(task.targetPath);
|
||||
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
|
||||
} catch {
|
||||
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? {}
|
||||
: {
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: -1,
|
||||
};
|
||||
|
||||
// Don't read activeTabId here - let SftpTabBar and SftpPaneWrapper subscribe to store
|
||||
// This prevents SftpViewInner from re-rendering on tab switch
|
||||
|
||||
const {
|
||||
leftPanes,
|
||||
rightPanes,
|
||||
leftTabsInfo,
|
||||
rightTabsInfo,
|
||||
showHostPickerLeft,
|
||||
showHostPickerRight,
|
||||
hostSearchLeft,
|
||||
hostSearchRight,
|
||||
setShowHostPickerLeft,
|
||||
setShowHostPickerRight,
|
||||
setHostSearchLeft,
|
||||
setHostSearchRight,
|
||||
handleAddTabLeft,
|
||||
handleAddTabRight,
|
||||
handleCloseTabLeft,
|
||||
handleCloseTabRight,
|
||||
handleSelectTabLeft,
|
||||
handleSelectTabRight,
|
||||
handleReorderTabsLeft,
|
||||
handleReorderTabsRight,
|
||||
handleMoveTabFromLeftToRight,
|
||||
handleMoveTabFromRightToLeft,
|
||||
handleDuplicateTabLeft,
|
||||
handleDuplicateTabRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef, hosts: effectiveHosts });
|
||||
|
||||
const handleAddTabLeftWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabLeft();
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handleAddTabLeft, handlePaneFocus]);
|
||||
|
||||
const handleAddTabRightWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabRight();
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handleAddTabRight, handlePaneFocus]);
|
||||
|
||||
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabLeft(tabId);
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabLeft]);
|
||||
|
||||
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabRight(tabId);
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabRight]);
|
||||
|
||||
const handleDuplicateTabLeftWithFocus = useCallback(
|
||||
async (...args: Parameters<typeof handleDuplicateTabLeft>) => {
|
||||
const tabId = await handleDuplicateTabLeft(...args);
|
||||
if (tabId) {
|
||||
handlePaneFocus("left", tabId);
|
||||
}
|
||||
},
|
||||
[handleDuplicateTabLeft, handlePaneFocus],
|
||||
);
|
||||
|
||||
const handleDuplicateTabRightWithFocus = useCallback(
|
||||
async (...args: Parameters<typeof handleDuplicateTabRight>) => {
|
||||
const tabId = await handleDuplicateTabRight(...args);
|
||||
if (tabId) {
|
||||
handlePaneFocus("right", tabId);
|
||||
}
|
||||
},
|
||||
[handleDuplicateTabRight, handlePaneFocus],
|
||||
);
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={effectiveHosts}
|
||||
connectedHosts={connectedHosts}
|
||||
writableHosts={hostWriteSource}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"absolute inset-0 min-h-0 flex flex-col bg-background",
|
||||
isActive ? "z-20" : "",
|
||||
)}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div
|
||||
ref={splitSurfaceRef}
|
||||
className="relative flex-1 min-h-0 border-t border-border/70 overflow-hidden"
|
||||
data-section="sftp-main-split"
|
||||
data-magnified-side={magnifiedSide ?? undefined}
|
||||
>
|
||||
{magnifiedSide && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 bg-background/55 backdrop-blur-[1px]"
|
||||
aria-hidden="true"
|
||||
data-section="pane-magnification-backdrop"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="absolute min-w-0 border-r border-border/70 bg-background flex flex-col transition-[left,top,width,height] duration-150 ease-out"
|
||||
style={resolveTwoPaneMagnificationStyle('left', isWideSplit, magnifiedSide === 'left')}
|
||||
data-sftp-pane-side="left"
|
||||
inert={magnifiedSide === 'right' ? true : undefined}
|
||||
onClick={() => handlePaneFocus("left")}
|
||||
onFocusCapture={() => handlePaneFocus("left")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "left" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-50 pointer-events-none"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '12px 12px 0 0',
|
||||
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Left side tab bar - only show when there are tabs */}
|
||||
{leftTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
tabs={leftTabsInfo}
|
||||
side="left"
|
||||
onSelectTab={handleSelectTabLeftWithFocus}
|
||||
onCloseTab={handleCloseTabLeft}
|
||||
onAddTab={handleAddTabLeftWithFocus}
|
||||
onReorderTabs={handleReorderTabsLeft}
|
||||
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
||||
onDuplicateTab={handleDuplicateTabLeftWithFocus}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{leftPanes.map((pane, idx) => (
|
||||
<SftpPaneWrapper
|
||||
key={pane.id}
|
||||
side="left"
|
||||
paneId={pane.id}
|
||||
isFirstPane={idx === 0}
|
||||
>
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "left"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
|
||||
/>
|
||||
</SftpPaneWrapper>
|
||||
))}
|
||||
{/* Loading overlay for left pane - shown when loading text content */}
|
||||
{loadingTextContent && textEditorTarget?.side === "left" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-30">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{t("sftp.status.loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="absolute min-w-0 bg-background flex flex-col transition-[left,top,width,height] duration-150 ease-out"
|
||||
style={resolveTwoPaneMagnificationStyle('right', isWideSplit, magnifiedSide === 'right')}
|
||||
data-sftp-pane-side="right"
|
||||
inert={magnifiedSide === 'left' ? true : undefined}
|
||||
onClick={() => handlePaneFocus("right")}
|
||||
onFocusCapture={() => handlePaneFocus("right")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "right" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-50 pointer-events-none"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '12px 12px 0 0',
|
||||
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Right side tab bar - only show when there are tabs */}
|
||||
{rightTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
tabs={rightTabsInfo}
|
||||
side="right"
|
||||
onSelectTab={handleSelectTabRightWithFocus}
|
||||
onCloseTab={handleCloseTabRight}
|
||||
onAddTab={handleAddTabRightWithFocus}
|
||||
onReorderTabs={handleReorderTabsRight}
|
||||
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
||||
onDuplicateTab={handleDuplicateTabRightWithFocus}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{rightPanes.map((pane, idx) => (
|
||||
<SftpPaneWrapper
|
||||
key={pane.id}
|
||||
side="right"
|
||||
paneId={pane.id}
|
||||
isFirstPane={idx === 0}
|
||||
>
|
||||
<SftpPaneView
|
||||
side="right"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "right"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
|
||||
/>
|
||||
</SftpPaneWrapper>
|
||||
))}
|
||||
{/* Loading overlay for right pane - shown when loading text content */}
|
||||
{loadingTextContent && textEditorTarget?.side === "right" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-30">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{t("sftp.status.loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{magnifiedSide && showMagnificationHint && (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-4 right-4 z-[60] rounded border border-border/70 bg-background/90 px-2 py-1 text-[10px] text-muted-foreground shadow-sm animate-in fade-in duration-150"
|
||||
data-section="pane-magnification-hint"
|
||||
>
|
||||
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
|
||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SftpOverlays
|
||||
hosts={effectiveHosts}
|
||||
connectedHosts={connectedHosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
onRevealTransferTarget={handleRevealTransferTarget}
|
||||
canCopyTransferTargetPath={canCopyTransferTargetPath}
|
||||
onCopyTransferTargetPath={handleCopyTransferTargetPath}
|
||||
showHostPickerLeft={showHostPickerLeft}
|
||||
showHostPickerRight={showHostPickerRight}
|
||||
hostSearchLeft={hostSearchLeft}
|
||||
hostSearchRight={hostSearchRight}
|
||||
setShowHostPickerLeft={setShowHostPickerLeft}
|
||||
setShowHostPickerRight={setShowHostPickerRight}
|
||||
setHostSearchLeft={setHostSearchLeft}
|
||||
setHostSearchRight={setHostSearchRight}
|
||||
handleHostSelectLeft={handleHostSelectLeft}
|
||||
handleHostSelectRight={handleHostSelectRight}
|
||||
permissionsState={permissionsState}
|
||||
setPermissionsState={setPermissionsState}
|
||||
showTextEditor={showTextEditor}
|
||||
setShowTextEditor={setShowTextEditor}
|
||||
textEditorTarget={textEditorTarget}
|
||||
setTextEditorTarget={setTextEditorTarget}
|
||||
textEditorContent={textEditorContent}
|
||||
setTextEditorContent={setTextEditorContent}
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</SftpContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
sftpPickerSessionsEqual(prev.sessions, next.sessions) &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.paneMagnificationRef === next.paneMagnificationRef &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
// Only these terminal connection settings affect SFTP connection
|
||||
// resolution today; compare them directly rather than the whole object
|
||||
// so unrelated terminal-setting changes don't tear the panel down.
|
||||
prev.terminalSettings?.verifyHostKeys === next.terminalSettings?.verifyHostKeys &&
|
||||
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
|
||||
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
|
||||
|
||||
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
|
||||
SftpView.displayName = "SftpView";
|
||||
234
components/SnippetExecutionProvider.tsx
Normal file
234
components/SnippetExecutionProvider.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { Snippet } from '../domain/models';
|
||||
import {
|
||||
applySnippetVariables,
|
||||
parseSnippetVariables,
|
||||
previewSnippetCommand,
|
||||
snippetHasVariables,
|
||||
type SnippetVariableDef,
|
||||
} from '../domain/snippetVariables';
|
||||
import {
|
||||
readSnippetVariableValuesForSnippet,
|
||||
saveSnippetVariableValues,
|
||||
} from '../application/state/snippetVariableValues';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
interface PendingPrompt {
|
||||
snippet: Snippet;
|
||||
variables: SnippetVariableDef[];
|
||||
resolve: (values: Record<string, string> | null) => void;
|
||||
}
|
||||
|
||||
function buildInitialValues(
|
||||
snippet: Snippet,
|
||||
variables: SnippetVariableDef[],
|
||||
): Record<string, string> {
|
||||
const cached = readSnippetVariableValuesForSnippet(snippet.id);
|
||||
const values: Record<string, string> = {};
|
||||
for (const def of variables) {
|
||||
if (cached[def.name] !== undefined) {
|
||||
values[def.name] = cached[def.name];
|
||||
} else if (def.defaultValue !== undefined) {
|
||||
values[def.name] = def.defaultValue;
|
||||
} else {
|
||||
values[def.name] = '';
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function isFormValid(
|
||||
variables: SnippetVariableDef[],
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
for (const def of variables) {
|
||||
const raw = values[def.name] ?? '';
|
||||
if (raw.trim() === '' && def.defaultValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const SnippetExecutionProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [pending, setPending] = useState<PendingPrompt | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const pendingRef = useRef<PendingPrompt | null>(null);
|
||||
pendingRef.current = pending;
|
||||
|
||||
const prompt = useCallback(
|
||||
(snippet: Snippet) =>
|
||||
new Promise<Record<string, string> | null>((resolve) => {
|
||||
const prior = pendingRef.current;
|
||||
if (prior) prior.resolve(null);
|
||||
|
||||
const variables = parseSnippetVariables(snippet.command);
|
||||
if (variables.length === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
setValues(buildInitialValues(snippet, variables));
|
||||
setPending({ snippet, variables, resolve });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
promptSnippetVariablesSingleton = prompt;
|
||||
return () => {
|
||||
promptSnippetVariablesSingleton = null;
|
||||
};
|
||||
}, [prompt]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const prior = pendingRef.current;
|
||||
if (prior) {
|
||||
prior.resolve(null);
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (!pending) return '';
|
||||
return previewSnippetCommand(pending.snippet.command, values);
|
||||
}, [pending, values]);
|
||||
|
||||
const canSubmit = pending ? isFormValid(pending.variables, values) : false;
|
||||
|
||||
const closeWith = useCallback((result: Record<string, string> | null) => {
|
||||
if (!pending) return;
|
||||
pending.resolve(result);
|
||||
setPending(null);
|
||||
}, [pending]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!pending || !canSubmit) return;
|
||||
const result = applySnippetVariables(pending.snippet.command, values);
|
||||
if (!result.ok) return;
|
||||
saveSnippetVariableValues(pending.snippet.id, values);
|
||||
closeWith(values);
|
||||
}, [pending, canSubmit, values, closeWith]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && canSubmit) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[canSubmit, handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Dialog open={!!pending} onOpenChange={(open) => { if (!open) closeWith(null); }}>
|
||||
<DialogContent className="sm:max-w-[520px]" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('snippets.variables.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.variables.dialogDesc', { label: pending?.snippet.label ?? '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{pending && (
|
||||
<div className="space-y-4 py-1">
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.variables.hint')}</p>
|
||||
<div className="space-y-3">
|
||||
{pending.variables.map((def) => {
|
||||
const raw = values[def.name] ?? '';
|
||||
const invalid = raw.trim() === '' && def.defaultValue === undefined;
|
||||
return (
|
||||
<div key={def.name} className="space-y-1.5">
|
||||
<Label htmlFor={`snippet-var-${def.name}`}>{def.name}</Label>
|
||||
<Input
|
||||
id={`snippet-var-${def.name}`}
|
||||
value={raw}
|
||||
placeholder={
|
||||
def.defaultValue !== undefined
|
||||
? t('snippets.variables.placeholderDefault', { value: def.defaultValue })
|
||||
: t('snippets.variables.placeholder')
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setValues((prev) => ({ ...prev, [def.name]: next }));
|
||||
}}
|
||||
className={invalid ? 'border-destructive' : undefined}
|
||||
autoFocus={def === pending.variables[0]}
|
||||
/>
|
||||
{invalid && (
|
||||
<p className="text-xs text-destructive">{t('snippets.variables.required')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-muted-foreground">
|
||||
{t('snippets.variables.preview')}
|
||||
</p>
|
||||
<ScrollArea className="max-h-32 rounded-md border border-border/60 bg-muted/30">
|
||||
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all">
|
||||
{preview}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={() => closeWith(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="default" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{t('snippets.variables.run')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
let promptSnippetVariablesSingleton:
|
||||
| ((snippet: Snippet) => Promise<Record<string, string> | null>)
|
||||
| null = null;
|
||||
|
||||
export async function resolveSnippetCommand(snippet: Snippet): Promise<string | null> {
|
||||
if (!snippetHasVariables(snippet.command)) {
|
||||
return snippet.command;
|
||||
}
|
||||
|
||||
const promptFn = promptSnippetVariablesSingleton;
|
||||
if (!promptFn) {
|
||||
return snippet.command;
|
||||
}
|
||||
|
||||
const values = await promptFn(snippet);
|
||||
if (values === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = applySnippetVariables(snippet.command, values);
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
return result.command;
|
||||
}
|
||||
62
components/SnippetScriptEditor.test.ts
Normal file
62
components/SnippetScriptEditor.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const snippetEditorSource = readFileSync(
|
||||
new URL('./snippets/SnippetScriptEditor.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const codeEditorSource = readFileSync(
|
||||
new URL('./scripts/ScriptCodeEditor.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('snippet editing uses the Monaco editor in both inline and expanded layouts', () => {
|
||||
assert.doesNotMatch(snippetEditorSource, /CodeTextarea/);
|
||||
assert.equal(snippetEditorSource.match(/<ScriptCodeEditor\s/g)?.length, 2);
|
||||
assert.equal(snippetEditorSource.match(/language="shell"/g)?.length, 2);
|
||||
assert.equal(snippetEditorSource.match(/placeholder=\{placeholder\}/g)?.length, 2);
|
||||
});
|
||||
|
||||
test('inline snippet editing preserves form focus behavior', () => {
|
||||
assert.match(snippetEditorSource, /inlineEditorRef\.current\?\.focus\(\)/);
|
||||
assert.match(snippetEditorSource, /\sref=\{inlineEditorRef\}/);
|
||||
assert.match(snippetEditorSource, /\stabFocusMode(?:\s|\n)/);
|
||||
assert.match(codeEditorSource, /tabFocusMode,/);
|
||||
});
|
||||
|
||||
test('snippet editor forwards the surrounding form submit shortcut', () => {
|
||||
assert.equal(snippetEditorSource.match(/onSubmitShortcut=\{onSubmitShortcut\}/g)?.length, 2);
|
||||
assert.match(codeEditorSource, /if \(onSubmitShortcut\)/);
|
||||
assert.match(codeEditorSource, /KeyMod\.CtrlCmd \| monacoInstance\.KeyCode\.Enter/);
|
||||
assert.match(codeEditorSource, /onSubmitShortcutRef\.current\?\.\(\)/);
|
||||
});
|
||||
|
||||
test('dialog submit handlers ignore shortcuts already handled by Monaco', () => {
|
||||
const quickAddSource = readFileSync(new URL('./QuickAddSnippetDialog.tsx', import.meta.url), 'utf8');
|
||||
const tmuxSource = readFileSync(
|
||||
new URL('./systemManager/TmuxNewSessionModal.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(quickAddSource, /if \(e\.defaultPrevented\) return/);
|
||||
assert.match(tmuxSource, /if \(e\.defaultPrevented\) return/);
|
||||
});
|
||||
|
||||
test('visual placeholder is hidden from assistive technology', () => {
|
||||
assert.match(codeEditorSource, /<span\s+aria-hidden/);
|
||||
});
|
||||
|
||||
test('script code editor restores Electron clipboard paste for Cmd/Ctrl+V and native right-click', () => {
|
||||
assert.match(codeEditorSource, /useClipboardBackend/);
|
||||
assert.match(codeEditorSource, /readClipboardTextWithFallbacks/);
|
||||
assert.match(codeEditorSource, /buildMonacoPasteEdits/);
|
||||
assert.match(codeEditorSource, /KeyMod\.CtrlCmd \| monacoInstance\.KeyCode\.KeyV/);
|
||||
assert.match(codeEditorSource, /contextmenu:\s*false/);
|
||||
assert.match(codeEditorSource, /editor\.action\.clipboardPasteAction/);
|
||||
});
|
||||
|
||||
test('script code editor pastes into Monaco find widget instead of script body', () => {
|
||||
assert.match(codeEditorSource, /pasteForMonacoEditorCommand/);
|
||||
assert.match(codeEditorSource, /activeElement:\s*document\.activeElement/);
|
||||
assert.match(codeEditorSource, /pasteIntoEditor:\s*\(\)\s*=>\s*handlePasteRef\.current\(\)/);
|
||||
});
|
||||
97
components/SnippetsHistoryItem.tsx
Normal file
97
components/SnippetsHistoryItem.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { ShellHistoryEntry } from '../types';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
// History Item Component
|
||||
interface HistoryItemProps {
|
||||
entry: ShellHistoryEntry;
|
||||
onSaveAsSnippet: (entry: ShellHistoryEntry, label: string) => void;
|
||||
onCopy: () => void;
|
||||
isCopied: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItem: React.FC<HistoryItemProps> = ({ entry, onSaveAsSnippet, onCopy, isCopied }) => {
|
||||
const { t } = useI18n();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [label, setLabel] = useState('');
|
||||
|
||||
const handleSave = () => {
|
||||
if (label.trim()) {
|
||||
onSaveAsSnippet(entry, label);
|
||||
setIsEditing(false);
|
||||
setLabel('');
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t('snippets.history.time.justNow');
|
||||
if (diffMins < 60) return t('snippets.history.time.minutesAgo', { count: diffMins });
|
||||
if (diffHours < 24) return t('snippets.history.time.hoursAgo', { count: diffHours });
|
||||
if (diffDays < 7) return t('snippets.history.time.daysAgo', { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group rounded-lg bg-background/60 border border-border/50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-sm truncate">{entry.command}</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-[11px] text-muted-foreground">
|
||||
<span>{entry.hostLabel}</span>
|
||||
<span>{t('snippets.history.separator')}</span>
|
||||
<span>{formatTime(entry.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={onCopy}
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 px-3"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Input
|
||||
placeholder={t('snippets.history.labelPlaceholder')}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setIsEditing(false); setLabel(''); }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!label.trim()}>
|
||||
{t('snippets.history.saveAsSnippet')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
139
components/SnippetsManager.transfer.test.tsx
Normal file
139
components/SnippetsManager.transfer.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Snippet } from "../types.ts";
|
||||
import { Dialog } from "./ui/dialog.tsx";
|
||||
import SnippetsManager, {
|
||||
SNIPPET_IMPORT_SAMPLE_FILES,
|
||||
SnippetImportDialogContent,
|
||||
buildSnippetImportSamplesZip,
|
||||
} from "./SnippetsManager.tsx";
|
||||
|
||||
const installStorageStub = () => {
|
||||
const values = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const snippet: Snippet = {
|
||||
id: "snippet-1",
|
||||
label: "Restart nginx",
|
||||
command: "sudo systemctl restart nginx",
|
||||
tags: [],
|
||||
package: "ops",
|
||||
targets: ["host-1"],
|
||||
};
|
||||
|
||||
test("SnippetsManager renders import and multi-select controls", () => {
|
||||
installStorageStub();
|
||||
const noop = () => {};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<SnippetsManager
|
||||
snippets={[snippet]}
|
||||
packages={["ops"]}
|
||||
hosts={[]}
|
||||
hotkeyScheme="mac"
|
||||
keyBindings={[]}
|
||||
onSave={noop}
|
||||
onBulkSave={noop}
|
||||
onDelete={noop}
|
||||
onPackagesChange={noop}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Import/);
|
||||
assert.match(markup, /Select snippets/);
|
||||
assert.doesNotMatch(markup, /Snippet import format/);
|
||||
});
|
||||
|
||||
test("SnippetImportDialog shows example JSON before import confirmation", () => {
|
||||
const noop = () => {};
|
||||
|
||||
const TestDialog = () => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = React.createRef<HTMLInputElement>();
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<SnippetImportDialogContent
|
||||
pendingImport={null}
|
||||
t={t}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileSelected={noop}
|
||||
onChooseFile={noop}
|
||||
onConfirmSkip={noop}
|
||||
onConfirmOverwrite={noop}
|
||||
onCancel={noop}
|
||||
onDownloadExamples={noop}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TestDialog />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Example JSON/);
|
||||
assert.match(markup, /netcatty\.snippets/);
|
||||
assert.match(markup, /JSON array/);
|
||||
assert.match(markup, /Choose file/);
|
||||
assert.match(markup, /Download samples/);
|
||||
assert.match(markup, /Confirm import/);
|
||||
assert.match(markup, /multiple=""/);
|
||||
});
|
||||
|
||||
test("snippet import sample files include standard and array formats", () => {
|
||||
assert.equal(SNIPPET_IMPORT_SAMPLE_FILES.length, 5);
|
||||
assert.ok(SNIPPET_IMPORT_SAMPLE_FILES.some((file) => file.name === "01-standard-netcatty-object.json"));
|
||||
assert.ok(SNIPPET_IMPORT_SAMPLE_FILES.some((file) => file.name === "02-plain-snippet-array.json"));
|
||||
assert.deepEqual(JSON.parse(SNIPPET_IMPORT_SAMPLE_FILES[1].content).map((item: { command: string }) => item.command), [
|
||||
"ss -lntp",
|
||||
"pwd",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSnippetImportSamplesZip returns a zip archive", async () => {
|
||||
const blob = buildSnippetImportSamplesZip();
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
|
||||
assert.equal(blob.type, "application/zip");
|
||||
assert.deepEqual(Array.from(bytes.slice(0, 4)), [0x50, 0x4b, 0x03, 0x04]);
|
||||
});
|
||||
|
||||
const snippetsManagerSource = readFileSync(new URL("./SnippetsManager.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("vault snippet package and snippet cards use asChild context-menu triggers", () => {
|
||||
assert.equal(snippetsManagerSource.match(/<ContextMenuTrigger asChild>/g)?.length, 2);
|
||||
assert.doesNotMatch(snippetsManagerSource, /<ContextMenuTrigger>(?! asChild)/);
|
||||
assert.match(snippetsManagerSource, /from '\.\/ui\/primaryOnlyDrag'/);
|
||||
assert.match(snippetsManagerSource, /primaryOnlyDragHandlers/);
|
||||
assert.match(snippetsManagerSource, /deleteSnippetPackage/);
|
||||
assert.match(snippetsManagerSource, /renameSnippetPackage/);
|
||||
assert.match(snippetsManagerSource, /SNIPPET_PACKAGE_PATH_CHANGE_EVENT/);
|
||||
assert.match(snippetsManagerSource, /applySnippetPackagePathChange/);
|
||||
assert.match(snippetsManagerSource, /setSelectedPackage/);
|
||||
assert.match(snippetsManagerSource, /setEditingSnippet/);
|
||||
assert.match(snippetsManagerSource, /setIsRenameDialogOpen\(false\)/);
|
||||
assert.match(snippetsManagerSource, /setRenamingPackagePath\(null\)/);
|
||||
assert.match(snippetsManagerSource, /deleteTarget\?\.type === 'package'/);
|
||||
assert.match(snippetsManagerSource, /setDeleteTarget\(null\)/);
|
||||
});
|
||||
2169
components/SnippetsManager.tsx
Normal file
2169
components/SnippetsManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
91
components/SnippetsPackageDialogs.tsx
Normal file
91
components/SnippetsPackageDialogs.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SnippetsPackageDialogsProps = Record<string, any>;
|
||||
|
||||
export const SnippetsPackageDialogs: React.FC<SnippetsPackageDialogsProps> = ({
|
||||
isPackageDialogOpen,
|
||||
t,
|
||||
selectedPackage,
|
||||
newPackageName,
|
||||
setNewPackageName,
|
||||
createPackage,
|
||||
setIsPackageDialogOpen,
|
||||
isRenameDialogOpen,
|
||||
renamingPackagePath,
|
||||
renamePackageName,
|
||||
setRenamePackageName,
|
||||
setRenameError,
|
||||
renamePackage,
|
||||
renameError,
|
||||
setIsRenameDialogOpen,
|
||||
}) => (
|
||||
<>
|
||||
{/* New Package Inline Form */}
|
||||
{isPackageDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<Card className="w-full max-w-sm p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t('snippets.packageDialog.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.packageDialog.parent', { parent: selectedPackage || t('snippets.packageDialog.root') })}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('field.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('snippets.packageDialog.placeholder')}
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsPackageDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={createPackage}>{t('common.create')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename Package Dialog */}
|
||||
{isRenameDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<Card className="w-full max-w-sm p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t('snippets.renameDialog.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.renameDialog.currentPath', { path: renamingPackagePath })}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('field.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('snippets.renameDialog.placeholder')}
|
||||
value={renamePackageName}
|
||||
onChange={(e) => {
|
||||
setRenamePackageName(e.target.value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && renamePackage()}
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="text-[11px] text-destructive">{renameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsRenameDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={renamePackage}>{t('common.rename')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
485
components/SnippetsRightPanel.tsx
Normal file
485
components/SnippetsRightPanel.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
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;
|
||||
};
|
||||
428
components/SyncStatusButton.tsx
Normal file
428
components/SyncStatusButton.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* SyncStatusButton - Cloud Sync Status Indicator for Top Bar
|
||||
*
|
||||
* Shows current sync state with cloud icon and colored indicators:
|
||||
* - Green dot: Local changes pending upload
|
||||
* - Blue dot + spin: Syncing in progress
|
||||
* - Red dot: Error
|
||||
* - Gray dot: No providers connected
|
||||
*
|
||||
* Clicking opens a popover with sync status details and history.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Cloud,
|
||||
CloudOff,
|
||||
Github,
|
||||
Loader2,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
X,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Database,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { isProviderReadyForSync, type CloudProvider, formatSyncDateTime } from '../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../domain/cloudProviderIds';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from './ui/popover';
|
||||
import { toast } from './ui/toast';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
// ============================================================================
|
||||
// Provider Icons
|
||||
// ============================================================================
|
||||
|
||||
const GoogleDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.71 3.5L1.15 15l3.43 6 6.55-11.5L7.71 3.5zm1.73 0l6.55 11.5H23L16.45 3.5H9.44zM8 15l-3.43 6h13.72l3.43-6H8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10.5 18.5c0 .55-.45 1-1 1h-5c-2.21 0-4-1.79-4-4 0-1.86 1.28-3.41 3-3.86v-.14c0-2.21 1.79-4 4-4 1.1 0 2.1.45 2.82 1.18A5.003 5.003 0 0 1 15 4c2.76 0 5 2.24 5 5 0 .16 0 .32-.02.47A4.5 4.5 0 0 1 24 13.5c0 2.49-2.01 4.5-4.5 4.5h-8c-.55 0-1-.45-1-1s.45-1 1-1h8c1.38 0 2.5-1.12 2.5-2.5s-1.12-2.5-2.5-2.5H19c-.28 0-.5-.22-.5-.5 0-2.21-1.79-4-4-4-1.87 0-3.44 1.28-3.88 3.02-.09.37-.41.63-.79.63-1.66 0-3 1.34-3 3v.5c0 .28-.22.5-.5.5-1.38 0-2.5 1.12-2.5 2.5s1.12 2.5 2.5 2.5h5c.55 0 1 .45 1 1z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const providerIcons: Partial<Record<CloudProvider, React.ReactNode>> = {
|
||||
github: <Github size={16} />,
|
||||
google: <GoogleDriveIcon className="w-4 h-4" />,
|
||||
onedrive: <OneDriveIcon className="w-4 h-4" />,
|
||||
webdav: <Server size={16} />,
|
||||
s3: <Database size={16} />,
|
||||
};
|
||||
|
||||
const providerNames: Partial<Record<CloudProvider, string>> = {
|
||||
github: 'GitHub Gist',
|
||||
google: 'Google Drive',
|
||||
onedrive: 'OneDrive',
|
||||
webdav: 'WebDAV',
|
||||
s3: 'S3 Compatible',
|
||||
};
|
||||
|
||||
const resolveProviderIcon = (provider: CloudProvider): React.ReactNode =>
|
||||
providerIcons[provider] ?? <Plug size={16} />;
|
||||
|
||||
const resolveProviderName = (provider: CloudProvider): string =>
|
||||
providerNames[provider] ?? provider;
|
||||
|
||||
// ============================================================================
|
||||
// Status Dot Component
|
||||
// ============================================================================
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
status: 'synced' | 'syncing' | 'error' | 'none';
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const StatusIndicator: React.FC<StatusIndicatorProps> = ({ status, size = 'sm', className }) => {
|
||||
const sizeClass = size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5';
|
||||
|
||||
const baseClasses = cn(
|
||||
'rounded-full',
|
||||
sizeClass,
|
||||
status === 'syncing' && 'animate-pulse',
|
||||
className
|
||||
);
|
||||
|
||||
const colors = {
|
||||
synced: 'bg-green-500',
|
||||
syncing: 'bg-blue-500',
|
||||
error: 'bg-red-500',
|
||||
none: 'bg-muted-foreground/30',
|
||||
};
|
||||
|
||||
return <span className={cn(baseClasses, colors[status])} />;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main SyncStatusButton Component
|
||||
// ============================================================================
|
||||
|
||||
interface SyncStatusButtonProps {
|
||||
onOpenSettings?: () => void;
|
||||
onSyncNow?: () => Promise<void>; // Callback to trigger sync with current data
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
onOpenSettings,
|
||||
onSyncNow,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSyncingManual, setIsSyncingManual] = useState(false);
|
||||
const sync = useCloudSync();
|
||||
|
||||
// State is now automatically synced via useSyncExternalStore - no manual refresh needed
|
||||
|
||||
// Get connected provider (built-ins first, then any namespaced plugin provider).
|
||||
const getConnectedProvider = (): CloudProvider | null => {
|
||||
const preferred: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const id of preferred) {
|
||||
const conn = sync.providers[id];
|
||||
if (conn && isProviderReadyForSync(conn)) return id;
|
||||
}
|
||||
for (const [id, conn] of Object.entries(sync.providers)) {
|
||||
if (preferred.includes(id as CloudProvider)) continue;
|
||||
if (conn && isProviderReadyForSync(conn)) return id as CloudProvider;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const connectedProvider = getConnectedProvider();
|
||||
const providerConnection = connectedProvider ? sync.providers[connectedProvider] : null;
|
||||
|
||||
const hasVersionMismatch = sync.hasAnyConnectedProvider
|
||||
&& sync.localVersion !== sync.remoteVersion;
|
||||
|
||||
const hasPendingSync = sync.pendingLocalSync || hasVersionMismatch;
|
||||
|
||||
// Determine overall status for the button indicator
|
||||
const getOverallStatus = (): StatusIndicatorProps['status'] => {
|
||||
if (sync.overallSyncStatus === 'syncing') return 'syncing';
|
||||
if (
|
||||
sync.overallSyncStatus === 'error' ||
|
||||
sync.overallSyncStatus === 'conflict' ||
|
||||
sync.overallSyncStatus === 'blocked'
|
||||
) {
|
||||
return 'error';
|
||||
}
|
||||
if (hasPendingSync) return 'synced';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
const overallStatus = getOverallStatus();
|
||||
|
||||
// Get the button icon based on state
|
||||
const getButtonIcon = () => {
|
||||
if (sync.isSyncing) return <Loader2 size={16} className="animate-spin" />;
|
||||
if (sync.hasAnyConnectedProvider) return <Cloud size={16} />;
|
||||
return <CloudOff size={16} />;
|
||||
};
|
||||
|
||||
const formatTime = (timestamp?: number): string => {
|
||||
if (!timestamp) return t('time.never');
|
||||
const diff = Date.now() - timestamp;
|
||||
if (diff < 60000) return t('time.justNow');
|
||||
if (diff < 3600000) return t('time.minutesAgo', { minutes: Math.floor(diff / 60000) });
|
||||
return formatSyncDateTime(timestamp);
|
||||
};
|
||||
|
||||
// Create a unique key based on sync state to force re-render
|
||||
const syncStateKey = `${sync.localVersion}-${sync.remoteVersion}-${sync.syncHistory.length}`;
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 relative app-no-drag top-tab-utility-btn",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{getButtonIcon()}
|
||||
|
||||
{overallStatus !== 'none' && (
|
||||
<StatusIndicator
|
||||
status={overallStatus}
|
||||
size="sm"
|
||||
className="absolute top-0.5 right-0.5 ring-2 ring-background"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sync.cloudSync')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
key={syncStateKey}
|
||||
className="w-80 p-0"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2.5 border-b border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{overallStatus === 'synced' && hasPendingSync && (
|
||||
<Cloud size={16} className="text-green-500" />
|
||||
)}
|
||||
{overallStatus === 'syncing' && (
|
||||
<Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||
)}
|
||||
{overallStatus === 'error' && (
|
||||
<Cloud size={16} className="text-red-500" />
|
||||
)}
|
||||
{overallStatus === 'none' && sync.hasAnyConnectedProvider && (
|
||||
<Cloud size={16} className="text-muted-foreground" />
|
||||
)}
|
||||
{overallStatus === 'none' && !sync.hasAnyConnectedProvider && (
|
||||
<CloudOff size={16} className="text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<span className="text-sm font-medium">
|
||||
{overallStatus === 'synced' && hasPendingSync && t('sync.pending')}
|
||||
{overallStatus === 'syncing' && t('sync.syncing')}
|
||||
{overallStatus === 'error' && t('sync.error')}
|
||||
{overallStatus === 'none' && sync.hasAnyConnectedProvider && t('sync.active')}
|
||||
{overallStatus === 'none' && !sync.hasAnyConnectedProvider && t('sync.notConfigured')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onOpenSettings && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sync.settings')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content based on state */}
|
||||
<div className="p-3">
|
||||
{!sync.hasAnyConnectedProvider ? (
|
||||
// No provider connected
|
||||
<div className="text-center py-4">
|
||||
<CloudOff size={32} className="mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm font-medium mb-1">{t('sync.notConfigured')}</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{t('sync.autoSync.noProvider')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onOpenSettings?.();
|
||||
}}
|
||||
>
|
||||
{t('sync.settings')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Provider connected - show details
|
||||
<div className="space-y-3">
|
||||
{/* Connected Provider Info */}
|
||||
{connectedProvider && providerConnection && (
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||
{resolveProviderIcon(connectedProvider)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{resolveProviderName(connectedProvider)}</span>
|
||||
<StatusIndicator status={overallStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{providerConnection.account?.avatarUrl
|
||||
&& connectedProvider
|
||||
&& !isPluginCloudProviderId(connectedProvider) && (
|
||||
<img
|
||||
src={providerConnection.account.avatarUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded-full"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{providerConnection.account?.name ||
|
||||
providerConnection.account?.email ||
|
||||
t('sync.connected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-muted/30">
|
||||
<div className="text-[10px] text-muted-foreground uppercase mb-0.5">
|
||||
{t('cloudSync.conflict.local')}
|
||||
</div>
|
||||
<div className="text-sm font-medium">v{sync.localVersion}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{formatTime(sync.localUpdatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-muted/30">
|
||||
<div className="text-[10px] text-muted-foreground uppercase mb-0.5">
|
||||
{t('cloudSync.conflict.cloud')}
|
||||
</div>
|
||||
<div className="text-sm font-medium">v{sync.remoteVersion}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{formatTime(sync.remoteUpdatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Sync History */}
|
||||
{sync.syncHistory.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1.5">
|
||||
{t('sync.recentActivity')}
|
||||
</div>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{sync.syncHistory.slice(0, 5).map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2 text-xs py-1">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
|
||||
entry.success ? "bg-green-500/10 text-green-500" : "bg-red-500/10 text-red-500"
|
||||
)}>
|
||||
{entry.success ? (
|
||||
entry.action === 'upload' ? <ArrowUp size={10} /> : <ArrowDown size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground flex-1 truncate">
|
||||
{entry.action === 'upload'
|
||||
? t('sync.history.uploaded')
|
||||
: entry.action === 'download'
|
||||
? t('sync.history.downloaded')
|
||||
: t('sync.history.resolved')}{' '}
|
||||
v{entry.localVersion}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60 shrink-0">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full gap-1"
|
||||
disabled={sync.isSyncing || isSyncingManual}
|
||||
onClick={async () => {
|
||||
if (onSyncNow) {
|
||||
setIsSyncingManual(true);
|
||||
try {
|
||||
await onSyncNow();
|
||||
toast.success(t('sync.toast.completedMessage'), t('sync.cloudSync'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('sync.failed'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
} finally {
|
||||
setIsSyncingManual(false);
|
||||
}
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
onOpenSettings?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(sync.isSyncing || isSyncingManual) ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
{t('sync.syncNow')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncStatusButton;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user