Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
Some checks failed
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

This commit is contained in:
2026-10-15 13:53:04 +08:00
commit 2f9f8da48e
3299 changed files with 926841 additions and 0 deletions

View 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/);
}
});

View 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>
);
};

View 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' });
}

View 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);
});

File diff suppressed because it is too large Load Diff

View 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
// -------------------------------------------------------------------

View 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');
});

View 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)}`;
}

View 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\(\)/);
});

File diff suppressed because it is too large Load Diff

View 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
View 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,
});

View 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();
}
});

View 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"/,
);
});

View 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>
);
};

View 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
View 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;

View 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/);
});

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface AppWordmarkProps {
accessibleLabel?: string;
className?: string;
text?: string;
}
/** 产品品牌文字 — 默认显示 productName,可通过 text 覆盖。 */
export const AppWordmark: React.FC<AppWordmarkProps> = ({ accessibleLabel, className, text }) => (
<span
className={`font-bold tracking-tight text-foreground ${className || ''}`}
aria-label={accessibleLabel}
role={accessibleLabel ? 'img' : undefined}
style={{ fontFamily: 'inherit', fontWeight: 700, letterSpacing: '-0.02em' }}
>
{text ?? accessibleLabel ?? 'NetMesh'}
</span>
);
export default AppWordmark;

View 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",
);
});

File diff suppressed because it is too large Load Diff

View 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\]/);
});

View 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);

View 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>
);
};

View 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
View 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";

View 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;

View 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/,
);
}
});

View 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();
}
});

View 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"]);
});

View 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>
);
}

View 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();
});

View 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,
);
});

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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';

View 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/);
});

View 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>
</>
);
};

View 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"/);
});

View 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>
</>
);
};

View 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),
};
};

View 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 &amp; 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",
);
});

View 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\}/);
});

File diff suppressed because it is too large Load Diff

View 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);
});

View 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>
);
};

View 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

File diff suppressed because it is too large Load Diff

View 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\(\)\}/);
});

View 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;

View 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);
});

View 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>
</>
);
};

View 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/);
});

View 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>
</>
);
};

File diff suppressed because it is too large Load Diff

View 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);

View 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
View 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);

View 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);
});

View 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;

View 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>
);
};

View 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"/);
});

File diff suppressed because it is too large Load Diff

View 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/);
});

View 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;

View 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/);
});

View 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",
});
});

View 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;

View 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"/);
});

View 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;

View 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</);
});

View 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;

View 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',
}]);
});

View 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";

View 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/);
});

View 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/);
});

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View 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;

View 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>
);
};

View 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;

View 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;

View File

@@ -0,0 +1,248 @@
import React, { useEffect, useState } from "react";
import { ArrowUpCircle, Check, Loader2, RefreshCcw } from "lucide-react";
import AppLogo from "./AppLogo";
import AppWordmark from "./AppWordmark";
import { Button } from "./ui/button";
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";
import { useStoredString } from "../application/state/useStoredString";
import { STORAGE_KEY_MONITORING_DASHBOARD_TITLE, STORAGE_KEY_REPORT_PRODUCT_NAME } from "../infrastructure/config/storageKeys";
type AppInfo = {
name: string;
version: string;
platform?: string;
};
interface SettingsApplicationTabProps {
updateState: UpdateState;
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
startDownload: UseUpdateCheckResult['startDownload'];
isUpdateDemoMode: boolean;
productName: string;
setProductName: (value: string) => void;
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode, productName, setProductName }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
const [monitoringTitle, setMonitoringTitle] = useStoredString<string>(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, '云启Net智能巡检监测系统');
const [reportProductName, setReportProductName] = useStoredString<string>(STORAGE_KEY_REPORT_PRODUCT_NAME, '');
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);
};
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={productName} text={productName} 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>
{/* 检查更新按钮已隐藏 */}
{/* 产品名称配置 —— 本地 state 缓冲输入,blur/Enter 才提交,避免每次按键都触发全局持久化 + IPC 导致闪动 */}
<SettingsAnchor anchorId="application-product-name" className="mt-6">
<ProductNameInput value={productName} onChange={setProductName} />
</SettingsAnchor>
{/* 大屏名称配置 —— 同样用本地 state 缓冲,避免每次按键都触发 localStorage 写入和事件广播 */}
<SettingsAnchor anchorId="application-monitoring-title" className="mt-6">
<SettingsInputBuffered
value={monitoringTitle}
placeholder="云启Net智能巡检监测系统"
label="大屏标题"
hint="3D 拓扑监控大屏页面顶部显示的系统标题(回车或失焦后生效)"
onCommit={(v) => {
setMonitoringTitle(v);
try { window.dispatchEvent(new CustomEvent('monitoringTitleChanged')); } catch {}
}}
/>
</SettingsAnchor>
{/* 报告产品名配置 —— 专门用于 PDF 巡检报告标题 */}
<SettingsAnchor anchorId="application-report-product-name" className="mt-6">
<SettingsInputBuffered
value={reportProductName}
placeholder={productName}
label="报告产品名"
hint="巡检报告 PDF 首页显示的产品名称(回车或失焦后生效)。留空则使用上方「产品名称」。"
trim
onCommit={(v) => setReportProductName(v)}
/>
</SettingsAnchor>
</div>
</div>
</SettingsTabContent>
);
}
/**
* 带本地 state 缓冲的设置输入框。输入过程只更新本地 draft,
* 回车或失焦才调用 onCommit,避免每次按键都触发全局持久化 + IPC 导致闪动。
*/
function SettingsInputBuffered({
value,
onCommit,
placeholder,
label,
hint,
trim = false,
}: {
value: string;
onCommit: (v: string) => void;
placeholder?: string;
label: string;
hint?: string;
trim?: boolean;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const commit = () => {
const next = trim ? draft.trim() : draft;
if (next !== value) onCommit(next);
};
return (
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">{label}</label>
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(value);
(e.target as HTMLInputElement).blur();
}
}}
placeholder={placeholder}
className="w-full px-3 py-2 rounded-lg border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
/**
* 产品名称输入框 —— SettingsInputBuffered + trim 空白。
*/
function ProductNameInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<SettingsInputBuffered
value={value}
onCommit={onChange}
placeholder="云启智能运维平台"
label="产品名称"
hint="用于窗口标题、侧边栏和设置页面(回车或失焦后生效)"
trim
/>
);
}

567
components/SettingsPage.tsx Normal file
View File

@@ -0,0 +1,567 @@
/**
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
*/
import { AppWindow, FileType, 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 { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
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 LazySettingsSystemTab = lazy(() => import("./settings/tabs/SettingsSystemTab"));
const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab"));
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 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>
</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}
productName={settings.productName}
setProductName={settings.setProductName}
/>
</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("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>
);
}

View 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,
);
});

View 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

File diff suppressed because it is too large Load Diff

View 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}</>;
};

View 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/);
});

View 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
View 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";

View 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;
}

View 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\(\)/);
});

View 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>
);
};

View 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\)/);
});

File diff suppressed because it is too large Load Diff

View 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>
)}
</>
);

View 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;
};

View 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