import { Check, Eye, EyeOff, FileText, FolderPlus, Plus, Settings2, Tag, X, } from "lucide-react"; import React, { useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { useApplicationBackend, type SshAgentStatus } from "../application/state/useApplicationBackend"; import { applyGroupDefaults, resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig"; import { getEffectiveHostDistro, normalizePrimaryTelnetState, } from "../domain/host"; import { formatProxyConfigEndpoint, formatProxyConfigType, updateProxyConfigField, } from "../domain/proxyProfiles"; import { hasRequiredHostAuthCredential, resolveHostAuth, resolveHostAuthMethodForPersistence } from "../domain/sshAuth"; import { customThemeStore } from "../application/state/customThemeStore"; import { hasHostFontSizeOverride, hasHostThemeOverride, resolveHostTerminalFontSize, resolveHostTerminalThemeId, } from "../domain/terminalAppearance"; import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey } from "../types"; import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar"; import ThemeSelectPanel from "./ThemeSelectPanel"; import { AsidePanel, AsidePanelContent, AsidePanelFooter, type AsidePanelLayout, type AsidePanelResizeProps, } from "./ui/aside-panel"; import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections"; import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections"; import { LINUX_DISTRO_OPTION_IDS, parseOptionalPortInput, resolveDetailsTelnetPassword, resolveDetailsTelnetPort, resolveDetailsTelnetUsername, resolvePrimaryProtocolSavePort, resolvePrimaryProtocolSwitchPort, prepareTelnetCredentialsForSave, prepareProxyConfigForSave, } from "./HostDetailsPanel.helpers"; export { parseOptionalPortInput } from "./HostDetailsPanel.helpers"; import { TerminalEncodingSelect } from "./TerminalEncodingSelect"; import { Button } from "./ui/button"; import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox"; import { Input } from "./ui/input"; import { Switch } from "./ui/switch"; import { toast } from "./ui/toast"; import { ChainPanel, CreateGroupPanel, HostDetailsSection, EnvVarsPanel, ProxyPanel, } from "./host-details"; import { HostNotesEditor } from "./host/HostNotesEditor"; import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection"; import { ensureHostConnectScriptIds, getEditableHostConnectScriptIds, syncSnippetsForHostConnectQueueSave } from "@/domain/hostConnectScripts.ts"; import { isPluginHostProtocol, sanitizePluginConnection, stripBuiltInConnectionFieldsForPluginHost, } from "../domain/pluginConnection"; import { PluginConnectionSection } from "./PluginConnectionSection"; type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null; type SubPanel = | "none" | "create-group" | "proxy" | "chain" | "env-vars" | "theme-select" | "telnet-theme-select"; interface HostDetailsPanelProps { initialData?: Host | null; availableKeys: SSHKey[]; identities: Identity[]; proxyProfiles?: ProxyProfile[]; groups: string[]; managedSources?: ManagedSource[]; allTags?: string[]; // All available tags for autocomplete allHosts?: Host[]; // All hosts for chain selection defaultGroup?: string | null; // Default group for new hosts (from current navigation) terminalThemeId: string; terminalFontSize: number; onSave: (host: Host) => void; onCancel: () => void; onCreateGroup?: (groupPath: string) => void; // Callback to create a new group onCreateTag?: (tag: string) => void; // Callback to create a new tag groupDefaults?: Partial; groupConfigs?: GroupConfig[]; layout?: AsidePanelLayout; onImportKey?: (draft: Partial) => SSHKey; snippets?: Snippet[]; onSnippetsChange?: (snippets: Snippet[]) => void; onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void; className?: string; } type HostDetailsPanelPropsWithResize = HostDetailsPanelProps & AsidePanelResizeProps; const HostDetailsPanel: React.FC = ({ initialData, availableKeys, identities, proxyProfiles = [], groups, managedSources = [], allTags = [], allHosts = [], defaultGroup, terminalThemeId, terminalFontSize, onSave, onCancel, onCreateGroup, onCreateTag, groupDefaults, groupConfigs = [], layout = "overlay", onImportKey, snippets = [], onSnippetsChange, onHostsChange, className, resizable, persistWidthStorageKey, resizeAriaLabel, }) => { const { t } = useI18n(); const asideResizeProps = { resizable, persistWidthStorageKey, resizeAriaLabel, }; const { checkSshAgent } = useApplicationBackend(); const baselineSnippetsRef = useRef(snippets); const baselineHydratedRef = useRef(snippets.length > 0); const openingQueueIdsRef = useRef([]); const [form, setForm] = useState( () => (initialData ? normalizePrimaryTelnetState(initialData) : null) || ({ id: crypto.randomUUID(), label: "", hostname: "", port: groupDefaults?.port ? undefined : 22, username: groupDefaults?.username ? "" : "root", protocol: "ssh", tags: [], os: "linux", authMethod: undefined, authPolicyVersion: 1, charset: groupDefaults?.charset ? undefined : "UTF-8", distroMode: "auto", createdAt: Date.now(), group: defaultGroup || undefined, // Pre-fill with current navigation group } as Host), ); const [activeSubPanel, setActiveSubPanel] = useState("none"); const [credentialPopoverOpen, setCredentialPopoverOpen] = useState(false); const [selectedCredentialType, setSelectedCredentialType] = useState(null); const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false); const [showPassword, setShowPassword] = useState(false); const [showTelnetPassword, setShowTelnetPassword] = useState(false); const [showAlgorithmOverrides, setShowAlgorithmOverrides] = useState(false); const [showNotesEditor, setShowNotesEditor] = useState(() => Boolean(initialData?.notes?.trim())); const [pluginConnectionValid, setPluginConnectionValid] = useState(true); const [newKeyFilePath, setNewKeyFilePath] = useState(""); const [pendingReferenceKeyPath, setPendingReferenceKeyPath] = useState(null); const [newGroupName, setNewGroupName] = useState(""); const [newGroupParent, setNewGroupParent] = useState(""); const [sshAgentStatus, setSshAgentStatus] = useState(null); const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState(null); useEffect(() => { let cancelled = false; if (form.agentForwarding || form.useSshAgent === true) { void checkSshAgent({ identityAgent: form.useSshAgent === true ? form.identityAgent : undefined, hostname: form.hostname, port: form.port, username: form.username, }).then((status) => { if (!cancelled) setSshAgentStatus(status); }); } else { setSshAgentStatus(null); } return () => { cancelled = true; }; }, [form.agentForwarding, form.useSshAgent, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]); useEffect(() => { let cancelled = false; if (form.agentForwarding) { void checkSshAgent({ identityAgent: form.identityAgent, agentForwarding: true, hostname: form.hostname, port: form.port, username: form.username, }).then((status) => { if (!cancelled) setSshForwardingAgentStatus(status); }); } else { setSshForwardingAgentStatus(null); } return () => { cancelled = true; }; }, [form.agentForwarding, form.identityAgent, form.hostname, form.port, form.username, checkSshAgent]); const [groupInputValue, setGroupInputValue] = useState(form.group || ""); const initialHostId = initialData?.id; useEffect(() => { baselineSnippetsRef.current = snippets; baselineHydratedRef.current = snippets.length > 0; if (!initialData) { openingQueueIdsRef.current = []; } else { const normalized = normalizePrimaryTelnetState(initialData); const ensured = snippets.length > 0 ? ensureHostConnectScriptIds(normalized, snippets) : normalized; openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets); } // Snapshot only when opening/reopening a host editor, not on live snippet edits. // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialHostId]); useEffect(() => { // If the editor opened before snippets hydrated, capture the first non-empty // catalog as baseline so concurrent-edit guards still have a reference. if (baselineHydratedRef.current) return; if (snippets.length === 0) return; baselineSnippetsRef.current = snippets; baselineHydratedRef.current = true; if (initialData) { const normalized = normalizePrimaryTelnetState(initialData); const ensured = ensureHostConnectScriptIds(normalized, snippets); openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets); } }, [initialData, snippets]); useEffect(() => { if (!initialData) return; const normalized = normalizePrimaryTelnetState(initialData); setForm( snippets.length > 0 ? ensureHostConnectScriptIds(normalized, snippets) : normalized, ); setGroupInputValue(initialData.group || ""); setPendingReferenceKeyPath(null); setShowPassword(false); setShowTelnetPassword(false); setShowNotesEditor(Boolean(normalized.notes?.trim())); // Reset only when opening a different host — not when snippets list updates. // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialHostId]); useEffect(() => { if (!initialData || snippets.length === 0) return; setForm((prev) => { if (prev.id !== initialData.id) return prev; const synced = ensureHostConnectScriptIds(prev, snippets); if ( synced.connectScriptIds === prev.connectScriptIds && synced.loginScriptId === prev.loginScriptId ) { return prev; } return synced; }); }, [initialData, snippets]); const update = (key: K, value: Host[K]) => { setForm((prev) => ({ ...prev, [key]: value })); }; const addLocalKeyFilePath = useCallback((path: string) => { const trimmed = path.trim(); if (!trimmed) return; setForm((prev) => ({ ...prev, identityFilePaths: onImportKey ? [trimmed] : [...(prev.identityFilePaths || []), trimmed], identityFileId: undefined, authMethod: "key", })); setPendingReferenceKeyPath(onImportKey ? trimmed : null); setNewKeyFilePath(""); setSelectedCredentialType(null); }, [onImportKey]); const effectiveGroupDefaults = useMemo(() => { const currentGroupPath = form.group || defaultGroup; if (currentGroupPath && groupConfigs.length > 0) { return resolveGroupDefaults(currentGroupPath, groupConfigs); } return groupDefaults; }, [defaultGroup, form.group, groupConfigs, groupDefaults]); const effectiveAuthHost = useMemo( () => effectiveGroupDefaults ? applyGroupDefaults(form, effectiveGroupDefaults) : form, [effectiveGroupDefaults, form], ); const selectedIdentity = useMemo(() => { if (!effectiveAuthHost.identityId) return undefined; return identities.find((i) => i.id === effectiveAuthHost.identityId); }, [effectiveAuthHost.identityId, identities]); const effectiveAuth = useMemo(() => resolveHostAuth({ host: effectiveAuthHost, keys: availableKeys, identities, }), [availableKeys, effectiveAuthHost, identities]); const effectiveAuthMethod = effectiveAuth.authMethod; const effectiveThemeId = useMemo( () => resolveHostTerminalThemeId(form, resolveGroupTerminalThemeId(effectiveGroupDefaults, terminalThemeId)), [effectiveGroupDefaults, form, terminalThemeId], ); const effectiveFontSize = useMemo( () => resolveHostTerminalFontSize(form, terminalFontSize), [form, terminalFontSize], ); const hasEffectiveThemeOverride = useMemo( () => hasHostThemeOverride(form), [form], ); const hasEffectiveFontSizeOverride = useMemo( () => hasHostFontSizeOverride(form), [form], ); const effectiveTelnetThemeId = form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId; const effectiveTelnetPort = resolveDetailsTelnetPort(form, effectiveGroupDefaults); const effectiveTelnetUsername = resolveDetailsTelnetUsername(form, effectiveGroupDefaults); const effectiveTelnetPassword = resolveDetailsTelnetPassword(form, effectiveGroupDefaults); const distroOptions = useMemo( () => LINUX_DISTRO_OPTION_IDS.map((value) => ({ value, label: t(`hostDetails.distro.option.${value}`), icon: DISTRO_LOGOS[value], bgClass: DISTRO_COLORS[value] || DISTRO_COLORS.default, })), [t], ); const getDistroOptionLabel = useCallback( (value?: string) => distroOptions.find((option) => option.value === value)?.label || value || t("hostDetails.distro.pending"), [distroOptions, t], ); const effectiveFormDistro = getEffectiveHostDistro(form); const selectedProxyProfile = useMemo( () => proxyProfiles.find((profile) => profile.id === form.proxyProfileId), [form.proxyProfileId, proxyProfiles], ); const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile); const proxySummaryType = hasMissingProxyProfile ? t("hostDetails.proxyPanel.missing") : formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP"; const proxySummaryLabel = hasMissingProxyProfile ? t("hostDetails.proxyPanel.missingSaved") : selectedProxyProfile ? selectedProxyProfile.label : formatProxyConfigEndpoint(form.proxyConfig); const proxySummaryTooltip = hasMissingProxyProfile ? t("hostDetails.proxyPanel.missingSaved") : selectedProxyProfile ? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}` : `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`; const handleDistroModeChange = useCallback((mode: "auto" | "manual") => { setForm((prev) => ({ ...prev, distroMode: mode, manualDistro: mode === "manual" ? prev.manualDistro || getEffectiveHostDistro(prev) || "linux" : prev.manualDistro, })); }, []); const updateProxyConfig = useCallback( (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => { setForm((prev) => { const { proxyProfileId: _proxyProfileId, ...rest } = prev; return { ...rest, proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value), } as Host; }); }, [], ); const clearProxyConfig = useCallback(() => { setForm((prev) => { const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev; return rest as Host; }); }, []); const selectProxyProfile = useCallback((profileId: string | undefined) => { setForm((prev) => { const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev; if (!profileId) return rest as Host; return { ...rest, proxyProfileId: profileId } as Host; }); }, []); const addHostToChain = (hostId: string) => { setForm((prev) => ({ ...prev, hostChain: { hostIds: [...(prev.hostChain?.hostIds || []), hostId], }, })); }; const removeHostFromChain = (index: number) => { setForm((prev) => { const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index); return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined }; }); }; const clearHostChain = useCallback(() => { setForm((prev) => { const { hostChain: _hostChain, ...rest } = prev; return rest as Host; }); }, []); const [newEnvName, setNewEnvName] = useState(""); const [newEnvValue, setNewEnvValue] = useState(""); const addEnvVar = () => { if (!newEnvName.trim()) return; const newVar: EnvVar = { name: newEnvName.trim(), value: newEnvValue }; setForm((prev) => ({ ...prev, environmentVariables: [...(prev.environmentVariables || []), newVar], })); setNewEnvName(""); setNewEnvValue(""); }; const removeEnvVar = (index: number) => { setForm((prev) => { const filtered = (prev.environmentVariables || []).filter((_, i) => i !== index); return { ...prev, environmentVariables: filtered.length > 0 ? filtered : undefined }; }); }; const pluginProtocolActive = isPluginHostProtocol(form.protocol); const hasHostname = form.hostname.trim().length > 0; const canSaveHost = pluginProtocolActive ? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol)) : hasHostname; const handleSubmit = () => { const hostname = form.hostname.trim() || (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : ""); if (!hostname || !canSaveHost) return; if (!pluginProtocolActive && !hasRequiredHostAuthCredential({ host: effectiveAuthHost, keys: availableKeys, identities })) { toast.error(t("hostDetails.auth.credentialRequired")); return; } const proxySave = pluginProtocolActive ? { normalizedProxyConfig: undefined, error: undefined } : prepareProxyConfigForSave({ proxyConfig: form.proxyConfig, proxyProfileId: form.proxyProfileId, proxyProfiles, identities, }); if (proxySave.error) { const messageKey = proxySave.error === "port" ? "proxyProfiles.error.port" : proxySave.error === "required" ? "hostDetails.proxyPanel.error.required" : proxySave.error === "missingSaved" ? "hostDetails.proxyPanel.missingSaved" : proxySave.error === "missingIdentity" ? "hostDetails.proxyPanel.missingIdentity" : proxySave.error === "incompleteIdentity" ? "hostDetails.proxyPanel.incompleteIdentity" : "hostDetails.proxyPanel.unreadableIdentity"; toast.error(t(messageKey)); setActiveSubPanel("proxy"); return; } const normalizedProxyConfig = proxySave.normalizedProxyConfig; let finalLabel = form.label?.trim() || hostname; const finalGroup = groupInputValue.trim() || form.group || ""; const targetManagedSource = managedSources .filter(s => finalGroup === s.groupName || finalGroup.startsWith(s.groupName + "/")) .sort((a, b) => b.groupName.length - a.groupName.length)[0]; const canBeManaged = !form.protocol || form.protocol === "ssh"; if (targetManagedSource && canBeManaged) { finalLabel = finalLabel.replace(/\s/g, ''); } let finalManagedSourceId: string | undefined; if (targetManagedSource && canBeManaged) { finalManagedSourceId = targetManagedSource.id; } else if (managedSources.length === 0 && form.managedSourceId && canBeManaged) { finalManagedSourceId = form.managedSourceId; } else { finalManagedSourceId = undefined; } const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form; const finalPort = resolvePrimaryProtocolSavePort( form.protocol, form.port, Boolean(groupDefaults?.port), Boolean(groupDefaults?.telnetPort), ); let cleaned: Host = { ...formWithoutProxyDraft, ...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }), hostname, label: finalLabel, group: finalGroup, tags: form.tags || [], notes: form.notes?.trim() || undefined, port: finalPort, password: form.savePassword === false ? undefined : form.password, authMethod: resolveHostAuthMethodForPersistence({ host: form, keys: availableKeys, identities, groupDefaults: effectiveGroupDefaults, }), authPolicyVersion: 1, managedSourceId: finalManagedSourceId, }; if (pluginProtocolActive) { const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol); if (!pluginConnection) return; cleaned = stripBuiltInConnectionFieldsForPluginHost({ ...cleaned, pluginConnection }); } cleaned = prepareTelnetCredentialsForSave(normalizePrimaryTelnetState(cleaned)); if ( onImportKey && pendingReferenceKeyPath && cleaned.identityFilePaths?.includes(pendingReferenceKeyPath) ) { const fileName = pendingReferenceKeyPath.split('/').pop() || pendingReferenceKeyPath; const key = onImportKey({ source: 'reference', filePath: pendingReferenceKeyPath, label: fileName, privateKey: '', category: 'key', }); cleaned = { ...cleaned, identityFileId: key.id, identityFilePaths: [pendingReferenceKeyPath], authMethod: "key", }; } const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false; const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false; const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false; if (cleaned.themeOverride === false) { delete cleaned.theme; } else if (preserveLegacyTheme && cleaned.theme == null) { cleaned.theme = initialData?.theme; } if (cleaned.fontFamilyOverride === false) { delete cleaned.fontFamily; } else if (preserveLegacyFontFamily && cleaned.fontFamily == null) { cleaned.fontFamily = initialData?.fontFamily; } if (cleaned.fontSizeOverride === false) { delete cleaned.fontSize; } else if (preserveLegacyFontSize && cleaned.fontSize == null) { cleaned.fontSize = initialData?.fontSize; } if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) { delete cleaned.x11Forwarding; } if (onSnippetsChange || onHostsChange) { const hostId = cleaned.id; if (snippets.length === 0 && !baselineHydratedRef.current) { // Vault may publish hosts before snippets hydrate. Do not wipe unresolved // connectScriptIds while the script catalog is still incomplete. } else { const savedQueueIds = openingQueueIdsRef.current; const finalQueueIds = getEditableHostConnectScriptIds(cleaned, snippets); const synced = syncSnippetsForHostConnectQueueSave( snippets, hostId, savedQueueIds, finalQueueIds, { baselineSnippets: baselineSnippetsRef.current, hosts: allHosts, }, ); cleaned = { ...cleaned, connectScriptIds: synced.connectScriptIds.length > 0 ? synced.connectScriptIds : undefined, // sanitizeHost reconstitutes connectScriptIds from loginScriptId when // the explicit queue is absent; clear the legacy field when emptied. ...(synced.connectScriptIds.length === 0 ? { loginScriptId: undefined } : {}), }; if (synced.changed && onSnippetsChange) { onSnippetsChange(synced.snippets); } if (onHostsChange && synced.hosts.length > 0) { const peerChanged = synced.hosts.some((host) => { if (host.id === cleaned.id) return false; const original = allHosts.find((entry) => entry.id === host.id); return original !== undefined && original !== host; }); if (peerChanged) { // Append only the script IDs this save newly queued onto each peer. // Leave the edited host alone so onSave can three-way merge it. onHostsChange((prevHosts) => { const peerAdds = new Map(); for (const syncedHost of synced.hosts) { if (syncedHost.id === cleaned.id) continue; const snapshot = allHosts.find((entry) => entry.id === syncedHost.id); if (!snapshot || snapshot === syncedHost) continue; const snapshotIds = snapshot.connectScriptIds ?? []; const addedIds = (syncedHost.connectScriptIds ?? []).filter( (id) => !snapshotIds.includes(id), ); if (addedIds.length > 0) peerAdds.set(syncedHost.id, addedIds); } return prevHosts.map((host) => { if (host.id === cleaned.id) return host; const addedIds = peerAdds.get(host.id); if (!addedIds || addedIds.length === 0) return host; const currentIds = host.connectScriptIds ?? []; const mergedIds = [...currentIds]; for (const id of addedIds) { if (!mergedIds.includes(id)) mergedIds.push(id); } if ( mergedIds.length === currentIds.length && mergedIds.every((id, index) => id === currentIds[index]) ) { return host; } return { ...host, connectScriptIds: mergedIds }; }); }); } } } } onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned)); }; const handleCreateGroup = () => { if (!newGroupName.trim()) return; const fullPath = newGroupParent ? `${newGroupParent}/${newGroupName.trim()}` : newGroupName.trim(); onCreateGroup?.(fullPath); setGroupInputValue(fullPath); update("group", fullPath); setNewGroupName(""); setNewGroupParent(""); setActiveSubPanel("none"); }; const availableHostsForChain = useMemo(() => { const chainedIds = new Set(form.hostChain?.hostIds || []); return allHosts.filter((h) => h.id !== form.id && !chainedIds.has(h.id)); }, [allHosts, form.id, form.hostChain?.hostIds]); const chainedHosts = useMemo(() => { const ids = form.hostChain?.hostIds || []; return ids .map((id) => allHosts.find((h) => h.id === id)) .filter(Boolean) as Host[]; }, [allHosts, form.hostChain?.hostIds]); const groupOptions: ComboboxOption[] = useMemo(() => { return groups.map((g) => ({ value: g, label: g.includes("/") ? g.split("/").pop()! : g, sublabel: g.includes("/") ? g : undefined, })); }, [groups]); const tagOptions: ComboboxOption[] = useMemo(() => { const allTagSet = new Set([...allTags, ...(form.tags || [])]); return Array.from(allTagSet).map((t) => ({ value: t, label: t })); }, [allTags, form.tags]); const keysByCategory = useMemo(() => { return { key: availableKeys.filter((k) => k.category === "key"), certificate: availableKeys.filter((k) => k.category === "certificate"), identity: availableKeys.filter((k) => k.category === "identity"), }; }, [availableKeys]); const selectedTelnetIdentity = useMemo(() => { if (!form.telnetIdentityId) return undefined; return identities.find((i) => i.id === form.telnetIdentityId); }, [form.telnetIdentityId, identities]); const telnetIdentityOptions: ComboboxOption[] = useMemo( () => identities.map((identity) => ({ value: identity.id, label: identity.label, sublabel: identity.username, })), [identities], ); const filteredIdentitySuggestions = useMemo(() => { if (selectedIdentity) return []; const q = (form.username || "").toLowerCase().trim(); const base = identities; const filtered = q ? base.filter( (i) => i.label.toLowerCase().includes(q) || i.username.toLowerCase().includes(q), ) : base; return filtered.slice(0, 6); }, [form.username, identities, selectedIdentity]); useEffect(() => { if (!identitySuggestionsOpen) return; if (filteredIdentitySuggestions.length === 0) { setIdentitySuggestionsOpen(false); } }, [filteredIdentitySuggestions.length, identitySuggestionsOpen]); const applyIdentity = useCallback( (identity: Identity) => { setForm((prev) => ({ ...prev, identityId: identity.id, username: identity.username, authMethod: identity.authMethod, password: undefined, identityFileId: undefined, identityFilePaths: undefined, })); setPendingReferenceKeyPath(null); setSelectedCredentialType(null); setCredentialPopoverOpen(false); setIdentitySuggestionsOpen(false); }, [], ); const clearIdentity = useCallback(() => { setForm((prev) => detachEffectiveHostIdentity(prev, effectiveAuth.username)); setIdentitySuggestionsOpen(false); }, [effectiveAuth.username]); const updateTelnetIdentity = useCallback((identityId: string) => { setForm((prev) => ({ ...prev, telnetIdentityId: identityId || undefined, ...(identityId ? { telnetUsername: undefined, telnetPassword: undefined, } : {}), })); }, []); if (activeSubPanel === "create-group") { return ( setActiveSubPanel("none")} onCancel={onCancel} layout={layout} {...asideResizeProps} /> ); } if (activeSubPanel === "proxy") { return ( setActiveSubPanel("none")} onCancel={onCancel} layout={layout} {...asideResizeProps} /> ); } if (activeSubPanel === "chain") { return ( setActiveSubPanel("none")} onCancel={onCancel} layout={layout} {...asideResizeProps} /> ); } if (activeSubPanel === "env-vars") { return ( { const newVars = [...(form.environmentVariables || [])]; newVars[index] = { ...newVars[index], [field]: value }; setForm((prev) => ({ ...prev, environmentVariables: newVars })); }} onSave={() => { if (newEnvName.trim()) addEnvVar(); setActiveSubPanel("none"); }} onBack={() => setActiveSubPanel("none")} onCancel={onCancel} layout={layout} {...asideResizeProps} /> ); } if (activeSubPanel === "theme-select") { return ( { if (themeId === effectiveThemeId && !hasEffectiveThemeOverride) { setActiveSubPanel("none"); return; } setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true })); setActiveSubPanel("none"); }} onClose={onCancel} onBack={() => setActiveSubPanel("none")} showBackButton={true} layout={layout} {...asideResizeProps} /> ); } if (activeSubPanel === "telnet-theme-select") { return ( { const telnetConfig = form.protocols?.find( (p) => p.protocol === "telnet", ); if (telnetConfig) { const newProtocols = form.protocols?.map((p) => p.protocol === "telnet" ? { ...p, theme: themeId } : p, ); setForm((prev) => ({ ...prev, protocols: newProtocols })); } else { const newProtocols = [ ...(form.protocols || []), { protocol: "telnet" as const, port: effectiveTelnetPort, enabled: true, theme: themeId, }, ]; setForm((prev) => ({ ...prev, protocols: newProtocols })); } setActiveSubPanel("none"); }} onClose={onCancel} onBack={() => setActiveSubPanel("none")} showBackButton={true} layout={layout} {...asideResizeProps} /> ); } return ( } > } title={t("hostDetails.section.general")} > { let value = e.target.value; const targetGroup = groupInputValue.trim() || form.group || ""; const willBeManaged = managedSources.some(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/") ); const canBeManaged = !form.protocol || form.protocol === "ssh"; if (willBeManaged && canBeManaged) { value = value.replace(/\s/g, ''); } update("label", value); }} className="h-10" />
{ update("group", val); setGroupInputValue(val); }} placeholder={t("hostDetails.group.placeholder")} allowCreate={true} onCreateNew={(val) => { onCreateGroup?.(val); update("group", val); setGroupInputValue(val); }} createText="Create Group" triggerClassName="flex-1 h-10" />
update("tags", vals)} placeholder="Add tags..." allowCreate={true} onCreateNew={(val) => onCreateTag?.(val)} createText="Create Tag" triggerClassName="flex-1 min-h-10" />
{!pluginProtocolActive ? : null} {onSnippetsChange ? ( ) : null} } title={t("hostDetails.notes.label")} hint={t("hostDetails.notes.help")} action={ } > {showNotesEditor ? ( update("notes", notes)} showHeader={false} defaultTab="edit" /> ) : null}
{t("hostDetails.otherProtocols")}
{form.telnetEnabled || form.protocol === "telnet" ? ( } title="Telnet" >
{t("hostDetails.telnetOn")}
update("telnetPort", parseOptionalPortInput(e.target.value))} className="h-8 flex-1 min-w-0 text-center" /> {t("hostDetails.port")}
{form.protocol !== "telnet" && ( )}
{t("hostDetails.telnet.setDefault")} { const nextProtocol = checked ? "telnet" : "ssh"; setForm((prev) => ({ ...prev, protocol: nextProtocol, port: resolvePrimaryProtocolSwitchPort( prev.port, nextProtocol, Boolean(groupDefaults?.telnetPort), Boolean(groupDefaults?.port), ), })); }} />

{t("hostDetails.telnet.credentials")}

{identities.length > 0 && ( )} {form.telnetIdentityId ? (
{selectedTelnetIdentity ? `${selectedTelnetIdentity.username} - ${selectedTelnetIdentity.label}` : t("hostDetails.identity.missing")}
) : ( <> update("telnetUsername" as keyof Host, e.target.value) } className="h-10" />
update("telnetPassword" as keyof Host, e.target.value) } className="h-10 pr-10" />
)} update("charset", value)} />
) : ( )} ); }; export default HostDetailsPanel;