From b7fdb2143d0df90a5b4622ce213bc595fa01d8ca Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 20 Dec 2025 01:15:25 -0600 Subject: [PATCH] fix: sudo incorrect styling and remove expiration date --- src/backend/database/db/index.ts | 2 - src/backend/database/db/schema.ts | 1 - src/backend/database/routes/ssh.ts | 18 +- src/hooks/use-confirmation.ts | 5 +- src/locales/en/translation.json | 2 +- src/types/index.ts | 2 - .../apps/host-manager/HostManagerEditor.tsx | 23 - .../apps/terminal/SudoPasswordPopup.tsx | 82 - src/ui/desktop/apps/terminal/Terminal.tsx | 2811 ++++++++--------- src/ui/desktop/user/UserProfile.tsx | 2 +- src/ui/main-axios.ts | 2 - 11 files changed, 1419 insertions(+), 1531 deletions(-) delete mode 100644 src/ui/desktop/apps/terminal/SudoPasswordPopup.tsx diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index b919c931..84a3232c 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -211,7 +211,6 @@ async function initializeCompleteDatabase(): Promise { docker_config TEXT, terminal_config TEXT, notes TEXT, - expiration_date TEXT, use_socks5 INTEGER, socks5_host TEXT, socks5_port INTEGER, @@ -578,7 +577,6 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); addColumnIfNotExists("ssh_data", "notes", "TEXT"); - addColumnIfNotExists("ssh_data", "expiration_date", "TEXT"); // SOCKS5 Proxy columns addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 52dfcc45..1371057b 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -94,7 +94,6 @@ export const sshData = sqliteTable("ssh_data", { terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), notes: text("notes"), - expirationDate: text("expiration_date"), useSocks5: integer("use_socks5", { mode: "boolean" }), socks5Host: text("socks5_host"), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 66559a09..25a6a39f 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -256,7 +256,6 @@ router.post( terminalConfig, forceKeyboardInteractive, notes, - expirationDate, useSocks5, socks5Host, socks5Port, @@ -316,7 +315,6 @@ router.post( terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", notes: notes || null, - expirationDate: expirationDate || null, useSocks5: useSocks5 ? 1 : 0, socks5Host: socks5Host || null, socks5Port: socks5Port || null, @@ -508,7 +506,6 @@ router.put( terminalConfig, forceKeyboardInteractive, notes, - expirationDate, useSocks5, socks5Host, socks5Port, @@ -518,9 +515,6 @@ router.put( overrideCredentialUsername, } = hostData; - // Temporary logging to debug notes and expirationDate - console.log("DEBUG - Update host data:", { notes, expirationDate }); - if ( !isNonEmptyString(userId) || !isNonEmptyString(ip) || @@ -566,7 +560,6 @@ router.put( terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", notes: notes || null, - expirationDate: expirationDate || null, useSocks5: useSocks5 ? 1 : 0, socks5Host: socks5Host || null, socks5Port: socks5Port || null, @@ -773,7 +766,6 @@ router.get( overrideCredentialUsername: sshData.overrideCredentialUsername, quickActions: sshData.quickActions, notes: sshData.notes, - expirationDate: sshData.expirationDate, enableDocker: sshData.enableDocker, useSocks5: sshData.useSocks5, socks5Host: sshData.socks5Host, @@ -1677,15 +1669,21 @@ async function resolveHostCredentials( if (credentials.length > 0) { const credential = credentials[0]; - return { + const resolvedHost: Record = { ...host, - username: credential.username, authType: credential.auth_type || credential.authType, password: credential.password, key: credential.key, keyPassword: credential.key_password || credential.keyPassword, keyType: credential.key_type || credential.keyType, }; + + // Only override username if overrideCredentialUsername is not enabled + if (!host.overrideCredentialUsername) { + resolvedHost.username = credential.username; + } + + return resolvedHost; } } diff --git a/src/hooks/use-confirmation.ts b/src/hooks/use-confirmation.ts index c8b7fe73..d927f0d0 100644 --- a/src/hooks/use-confirmation.ts +++ b/src/hooks/use-confirmation.ts @@ -56,6 +56,8 @@ export function useConfirmation() { }, duration: 10000, className: variant === "destructive" ? "border-red-500" : "", + actionButtonStyle: { marginLeft: "0.1rem" }, + cancelButtonStyle: { marginRight: "0.1rem" }, }); return Promise.resolve(true); } @@ -65,7 +67,8 @@ export function useConfirmation() { const options = opts as ConfirmationOptions; const actionText = options.confirmText || "Confirm"; const cancelText = options.cancelText || "Cancel"; - const variantClass = options.variant === "destructive" ? "border-red-500" : ""; + const variantClass = + options.variant === "destructive" ? "border-red-500" : ""; toast(options.title, { description: options.description, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5ea8054c..6f366c5f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1617,7 +1617,7 @@ "folder": "folder", "password": "password", "keyPassword": "key password", - "notes": "Add notes about this host...", + "notes": "add notes about this host...", "expirationDate": "Select expiration date", "pastePrivateKey": "Paste your private key here...", "pastePublicKey": "Paste your public key here...", diff --git a/src/types/index.ts b/src/types/index.ts index 3694e43b..fd97146c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -48,7 +48,6 @@ export interface SSHHost { statsConfig?: string | Record; terminalConfig?: TerminalConfig; notes?: string; - expirationDate?: string; useSocks5?: boolean; socks5Host?: string; @@ -110,7 +109,6 @@ export interface SSHHostData { statsConfig?: string | Record; terminalConfig?: TerminalConfig; notes?: string; - expirationDate?: string; // SOCKS5 Proxy configuration useSocks5?: boolean; diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index c95f7c19..9c2a7dcd 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -536,7 +536,6 @@ export function HostManagerEditor({ ) .default([]), notes: z.string().optional(), - expirationDate: z.string().optional(), useSocks5: z.boolean().optional(), socks5Host: z.string().optional(), socks5Port: z.coerce.number().min(1).max(65535).optional(), @@ -643,7 +642,6 @@ export function HostManagerEditor({ terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, notes: "", - expirationDate: "", useSocks5: false, socks5Host: "", socks5Port: 1080, @@ -747,7 +745,6 @@ export function HostManagerEditor({ }, forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), notes: cleanedHost.notes || "", - expirationDate: cleanedHost.expirationDate || "", useSocks5: Boolean(cleanedHost.useSocks5), socks5Host: cleanedHost.socks5Host || "", socks5Port: cleanedHost.socks5Port || 1080, @@ -1389,26 +1386,6 @@ export function HostManagerEditor({ )} /> - ( - - {t("hosts.expirationDate")} - - - - - )} - /> - void; - onDismiss: () => void; -} - -export function SudoPasswordPopup({ - isOpen, - hostPassword, - backgroundColor, - onConfirm, - onDismiss -}: SudoPasswordPopupProps) { - const { t } = useTranslation(); - - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - onConfirm(hostPassword); - } else if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - onDismiss(); - } - }; - - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [isOpen, onConfirm, onDismiss, hostPassword]); - - if (!isOpen) return null; - - return ( -
-
-
- -
-
-

- {t("terminal.sudoPasswordPopupTitle", "Insert password?")} -

-

- {t("terminal.sudoPasswordPopupHint", "Press Enter to insert, Esc to dismiss")} -

-
-
-
- - -
-
- ); -} diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 91d20672..6791e7eb 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -1,10 +1,10 @@ import { - useEffect, - useRef, - useState, - useImperativeHandle, - forwardRef, - useCallback, + useEffect, + useRef, + useState, + useImperativeHandle, + forwardRef, + useCallback, } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; @@ -14,17 +14,17 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { - getCookie, - isElectron, - logActivity, - getSnippets, + getCookie, + isElectron, + logActivity, + getSnippets, } from "@/ui/main-axios.ts"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; import { - TERMINAL_THEMES, - DEFAULT_TERMINAL_CONFIG, - TERMINAL_FONTS, + TERMINAL_THEMES, + DEFAULT_TERMINAL_CONFIG, + TERMINAL_FONTS, } from "@/constants/terminal-themes"; import type { TerminalConfig } from "@/types"; import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; @@ -32,1502 +32,1501 @@ import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useComman import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx"; import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; -import { SudoPasswordPopup } from "./SudoPasswordPopup.tsx"; +import { useConfirmation } from "@/hooks/use-confirmation.ts"; interface HostConfig { - id?: number; - ip: string; - port: number; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - authType?: string; - credentialId?: number; - terminalConfig?: TerminalConfig; - [key: string]: unknown; + id?: number; + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + authType?: string; + credentialId?: number; + terminalConfig?: TerminalConfig; + [key: string]: unknown; } interface TerminalHandle { - disconnect: () => void; - fit: () => void; - sendInput: (data: string) => void; - notifyResize: () => void; - refresh: () => void; + disconnect: () => void; + fit: () => void; + sendInput: (data: string) => void; + notifyResize: () => void; + refresh: () => void; } interface SSHTerminalProps { - hostConfig: HostConfig; - isVisible: boolean; - title?: string; - showTitle?: boolean; - splitScreen?: boolean; - onClose?: () => void; - initialPath?: string; - executeCommand?: string; + hostConfig: HostConfig; + isVisible: boolean; + title?: string; + showTitle?: boolean; + splitScreen?: boolean; + onClose?: () => void; + initialPath?: string; + executeCommand?: string; } export const Terminal = forwardRef( - function SSHTerminal( - { - hostConfig, - isVisible, - splitScreen = false, - onClose, - initialPath, - executeCommand, - }, - ref, + function SSHTerminal( + { + hostConfig, + isVisible, + splitScreen = false, + onClose, + initialPath, + executeCommand, + }, + ref, + ) { + if ( + typeof window !== "undefined" && + !(window as { testJWT?: () => string | null }).testJWT ) { - if ( - typeof window !== "undefined" && - !(window as { testJWT?: () => string | null }).testJWT - ) { - (window as { testJWT?: () => string | null }).testJWT = () => { - const jwt = getCookie("jwt"); - return jwt; - }; - } + (window as { testJWT?: () => string | null }).testJWT = () => { + const jwt = getCookie("jwt"); + return jwt; + }; + } - const { t } = useTranslation(); - const { instance: terminal, ref: xtermRef } = useXTerm(); - const commandHistoryContext = useCommandHistory(); + const { t } = useTranslation(); + const { instance: terminal, ref: xtermRef } = useXTerm(); + const commandHistoryContext = useCommandHistory(); + const { confirmWithToast } = useConfirmation(); - const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }; - const themeColors = - TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors; - const backgroundColor = themeColors.background; - const fitAddonRef = useRef(null); - const webSocketRef = useRef(null); - const resizeTimeout = useRef(null); - const wasDisconnectedBySSH = useRef(false); - const pingIntervalRef = useRef(null); - const [visible, setVisible] = useState(false); - const [isReady, setIsReady] = useState(false); - const [isConnected, setIsConnected] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [isFitted, setIsFitted] = useState(true); - const [, setConnectionError] = useState(null); - const [, setIsAuthenticated] = useState(false); - const [totpRequired, setTotpRequired] = useState(false); - const [totpPrompt, setTotpPrompt] = useState(""); - const [isPasswordPrompt, setIsPasswordPrompt] = useState(false); - const [showAuthDialog, setShowAuthDialog] = useState(false); - const [authDialogReason, setAuthDialogReason] = useState< - "no_keyboard" | "auth_failed" | "timeout" - >("no_keyboard"); - const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = - useState(false); - const isVisibleRef = useRef(false); - const isFittingRef = useRef(false); - const reconnectTimeoutRef = useRef(null); - const reconnectAttempts = useRef(0); - const maxReconnectAttempts = 3; - const isUnmountingRef = useRef(false); - const shouldNotReconnectRef = useRef(false); - const isReconnectingRef = useRef(false); - const isConnectingRef = useRef(false); - const connectionTimeoutRef = useRef(null); - const activityLoggedRef = useRef(false); - const keyHandlerAttachedRef = useRef(false); + const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }; + const themeColors = + TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors; + const backgroundColor = themeColors.background; + const fitAddonRef = useRef(null); + const webSocketRef = useRef(null); + const resizeTimeout = useRef(null); + const wasDisconnectedBySSH = useRef(false); + const pingIntervalRef = useRef(null); + const [visible, setVisible] = useState(false); + const [isReady, setIsReady] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [isFitted, setIsFitted] = useState(true); + const [, setConnectionError] = useState(null); + const [, setIsAuthenticated] = useState(false); + const [totpRequired, setTotpRequired] = useState(false); + const [totpPrompt, setTotpPrompt] = useState(""); + const [isPasswordPrompt, setIsPasswordPrompt] = useState(false); + const [showAuthDialog, setShowAuthDialog] = useState(false); + const [authDialogReason, setAuthDialogReason] = useState< + "no_keyboard" | "auth_failed" | "timeout" + >("no_keyboard"); + const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = + useState(false); + const isVisibleRef = useRef(false); + const isFittingRef = useRef(false); + const reconnectTimeoutRef = useRef(null); + const reconnectAttempts = useRef(0); + const maxReconnectAttempts = 3; + const isUnmountingRef = useRef(false); + const shouldNotReconnectRef = useRef(false); + const isReconnectingRef = useRef(false); + const isConnectingRef = useRef(false); + const connectionTimeoutRef = useRef(null); + const activityLoggedRef = useRef(false); + const keyHandlerAttachedRef = useRef(false); - const { trackInput, getCurrentCommand, updateCurrentCommand } = - useCommandTracker({ - hostId: hostConfig.id, - enabled: true, - onCommandExecuted: (command) => { - if (!autocompleteHistory.current.includes(command)) { - autocompleteHistory.current = [ - command, - ...autocompleteHistory.current, - ]; - } - }, - }); + const { trackInput, getCurrentCommand, updateCurrentCommand } = + useCommandTracker({ + hostId: hostConfig.id, + enabled: true, + onCommandExecuted: (command) => { + if (!autocompleteHistory.current.includes(command)) { + autocompleteHistory.current = [ + command, + ...autocompleteHistory.current, + ]; + } + }, + }); - const getCurrentCommandRef = useRef(getCurrentCommand); - const updateCurrentCommandRef = useRef(updateCurrentCommand); + const getCurrentCommandRef = useRef(getCurrentCommand); + const updateCurrentCommandRef = useRef(updateCurrentCommand); - useEffect(() => { - getCurrentCommandRef.current = getCurrentCommand; - updateCurrentCommandRef.current = updateCurrentCommand; - }, [getCurrentCommand, updateCurrentCommand]); + useEffect(() => { + getCurrentCommandRef.current = getCurrentCommand; + updateCurrentCommandRef.current = updateCurrentCommand; + }, [getCurrentCommand, updateCurrentCommand]); - const [showAutocomplete, setShowAutocomplete] = useState(false); - const [autocompleteSuggestions, setAutocompleteSuggestions] = useState< - string[] - >([]); - const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = - useState(0); - const [autocompletePosition, setAutocompletePosition] = useState({ - top: 0, - left: 0, + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompleteSuggestions, setAutocompleteSuggestions] = useState< + string[] + >([]); + const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = + useState(0); + const [autocompletePosition, setAutocompletePosition] = useState({ + top: 0, + left: 0, + }); + const autocompleteHistory = useRef([]); + const currentAutocompleteCommand = useRef(""); + + const showAutocompleteRef = useRef(false); + const autocompleteSuggestionsRef = useRef([]); + const autocompleteSelectedIndexRef = useRef(0); + + const [showHistoryDialog, setShowHistoryDialog] = useState(false); + const [commandHistory, setCommandHistory] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading); + const setCommandHistoryContextRef = useRef( + commandHistoryContext.setCommandHistory, + ); + + useEffect(() => { + setIsLoadingRef.current = commandHistoryContext.setIsLoading; + setCommandHistoryContextRef.current = + commandHistoryContext.setCommandHistory; + }, [ + commandHistoryContext.setIsLoading, + commandHistoryContext.setCommandHistory, + ]); + + useEffect(() => { + if (showHistoryDialog && hostConfig.id) { + setIsLoadingHistory(true); + setIsLoadingRef.current(true); + import("@/ui/main-axios.ts") + .then((module) => module.getCommandHistory(hostConfig.id!)) + .then((history) => { + setCommandHistory(history); + setCommandHistoryContextRef.current(history); + }) + .catch((error) => { + console.error("Failed to load command history:", error); + setCommandHistory([]); + setCommandHistoryContextRef.current([]); + }) + .finally(() => { + setIsLoadingHistory(false); + setIsLoadingRef.current(false); + }); + } + }, [showHistoryDialog, hostConfig.id]); + + useEffect(() => { + const autocompleteEnabled = + localStorage.getItem("commandAutocomplete") === "true"; + + if (hostConfig.id && autocompleteEnabled) { + import("@/ui/main-axios.ts") + .then((module) => module.getCommandHistory(hostConfig.id!)) + .then((history) => { + autocompleteHistory.current = history; + }) + .catch((error) => { + console.error("Failed to load autocomplete history:", error); + autocompleteHistory.current = []; + }); + } else { + autocompleteHistory.current = []; + } + }, [hostConfig.id]); + + useEffect(() => { + showAutocompleteRef.current = showAutocomplete; + }, [showAutocomplete]); + + useEffect(() => { + autocompleteSuggestionsRef.current = autocompleteSuggestions; + }, [autocompleteSuggestions]); + + useEffect(() => { + autocompleteSelectedIndexRef.current = autocompleteSelectedIndex; + }, [autocompleteSelectedIndex]); + + const activityLoggingRef = useRef(false); + const sudoPromptShownRef = useRef(false); + + const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); + const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); + const notifyTimerRef = useRef(null); + const lastFittedSizeRef = useRef<{ cols: number; rows: number } | null>( + null, + ); + const DEBOUNCE_MS = 140; + + const logTerminalActivity = async () => { + if ( + !hostConfig.id || + activityLoggedRef.current || + activityLoggingRef.current + ) { + return; + } + + activityLoggingRef.current = true; + activityLoggedRef.current = true; + + try { + const hostName = + hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`; + await logActivity("terminal", hostConfig.id, hostName); + } catch (err) { + console.warn("Failed to log terminal activity:", err); + activityLoggedRef.current = false; + } finally { + activityLoggingRef.current = false; + } + }; + + useEffect(() => { + isVisibleRef.current = isVisible; + }, [isVisible]); + + useEffect(() => { + const checkAuth = () => { + const jwtToken = getCookie("jwt"); + const isAuth = !!(jwtToken && jwtToken.trim() !== ""); + + setIsAuthenticated((prev) => { + if (prev !== isAuth) { + return isAuth; + } + return prev; }); - const autocompleteHistory = useRef([]); - const currentAutocompleteCommand = useRef(""); + }; - const showAutocompleteRef = useRef(false); - const autocompleteSuggestionsRef = useRef([]); - const autocompleteSelectedIndexRef = useRef(0); + checkAuth(); - const [showHistoryDialog, setShowHistoryDialog] = useState(false); - const [commandHistory, setCommandHistory] = useState([]); - const [isLoadingHistory, setIsLoadingHistory] = useState(false); - const [showSudoPasswordPopup, setShowSudoPasswordPopup] = useState(false); + const authCheckInterval = setInterval(checkAuth, 5000); - const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading); - const setCommandHistoryContextRef = useRef( - commandHistoryContext.setCommandHistory, - ); + return () => clearInterval(authCheckInterval); + }, []); - useEffect(() => { - setIsLoadingRef.current = commandHistoryContext.setIsLoading; - setCommandHistoryContextRef.current = - commandHistoryContext.setCommandHistory; - }, [ - commandHistoryContext.setIsLoading, - commandHistoryContext.setCommandHistory, - ]); - - useEffect(() => { - if (showHistoryDialog && hostConfig.id) { - setIsLoadingHistory(true); - setIsLoadingRef.current(true); - import("@/ui/main-axios.ts") - .then((module) => module.getCommandHistory(hostConfig.id!)) - .then((history) => { - setCommandHistory(history); - setCommandHistoryContextRef.current(history); - }) - .catch((error) => { - console.error("Failed to load command history:", error); - setCommandHistory([]); - setCommandHistoryContextRef.current([]); - }) - .finally(() => { - setIsLoadingHistory(false); - setIsLoadingRef.current(false); - }); - } - }, [showHistoryDialog, hostConfig.id]); - - useEffect(() => { - const autocompleteEnabled = - localStorage.getItem("commandAutocomplete") !== "false"; - - if (hostConfig.id && autocompleteEnabled) { - import("@/ui/main-axios.ts") - .then((module) => module.getCommandHistory(hostConfig.id!)) - .then((history) => { - autocompleteHistory.current = history; - }) - .catch((error) => { - console.error("Failed to load autocomplete history:", error); - autocompleteHistory.current = []; - }); - } else { - autocompleteHistory.current = []; - } - }, [hostConfig.id]); - - useEffect(() => { - showAutocompleteRef.current = showAutocomplete; - }, [showAutocomplete]); - - useEffect(() => { - autocompleteSuggestionsRef.current = autocompleteSuggestions; - }, [autocompleteSuggestions]); - - useEffect(() => { - autocompleteSelectedIndexRef.current = autocompleteSelectedIndex; - }, [autocompleteSelectedIndex]); - - const activityLoggingRef = useRef(false); - - const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); - const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); - const notifyTimerRef = useRef(null); - const lastFittedSizeRef = useRef<{ cols: number; rows: number } | null>( - null, - ); - const DEBOUNCE_MS = 140; - - const logTerminalActivity = async () => { - if ( - !hostConfig.id || - activityLoggedRef.current || - activityLoggingRef.current - ) { - return; - } - - activityLoggingRef.current = true; - activityLoggedRef.current = true; - - try { - const hostName = - hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`; - await logActivity("terminal", hostConfig.id, hostName); - } catch (err) { - console.warn("Failed to log terminal activity:", err); - activityLoggedRef.current = false; - } finally { - activityLoggingRef.current = false; - } - }; - - useEffect(() => { - isVisibleRef.current = isVisible; - }, [isVisible]); - - useEffect(() => { - const checkAuth = () => { - const jwtToken = getCookie("jwt"); - const isAuth = !!(jwtToken && jwtToken.trim() !== ""); - - setIsAuthenticated((prev) => { - if (prev !== isAuth) { - return isAuth; - } - return prev; - }); - }; - - checkAuth(); - - const authCheckInterval = setInterval(checkAuth, 5000); - - return () => clearInterval(authCheckInterval); - }, []); - - function hardRefresh() { - try { - if ( - terminal && - typeof ( - terminal as { refresh?: (start: number, end: number) => void } - ).refresh === "function" - ) { - ( - terminal as { refresh?: (start: number, end: number) => void } - ).refresh(0, terminal.rows - 1); - } - } catch (error) { - console.error("Terminal operation failed:", error); - } - } - - function performFit() { - if ( - !fitAddonRef.current || - !terminal || - !isVisibleRef.current || - isFittingRef.current - ) { - return; - } - - const lastSize = lastFittedSizeRef.current; - if ( - lastSize && - lastSize.cols === terminal.cols && - lastSize.rows === terminal.rows - ) { - return; - } - - isFittingRef.current = true; - - try { - fitAddonRef.current?.fit(); - if (terminal && terminal.cols > 0 && terminal.rows > 0) { - scheduleNotify(terminal.cols, terminal.rows); - lastFittedSizeRef.current = { - cols: terminal.cols, - rows: terminal.rows, - }; - } - setIsFitted(true); - } finally { - isFittingRef.current = false; - } - } - - function handleTotpSubmit(code: string) { - if (webSocketRef.current && code) { - webSocketRef.current.send( - JSON.stringify({ - type: isPasswordPrompt ? "password_response" : "totp_response", - data: { code }, - }), - ); - setTotpRequired(false); - setTotpPrompt(""); - setIsPasswordPrompt(false); - } - } - - function handleTotpCancel() { - setTotpRequired(false); - setTotpPrompt(""); - if (onClose) onClose(); - } - - function handleAuthDialogSubmit(credentials: { - password?: string; - sshKey?: string; - keyPassword?: string; - }) { - if (webSocketRef.current && terminal) { - webSocketRef.current.send( - JSON.stringify({ - type: "reconnect_with_credentials", - data: { - cols: terminal.cols, - rows: terminal.rows, - password: credentials.password, - sshKey: credentials.sshKey, - keyPassword: credentials.keyPassword, - hostConfig: { - ...hostConfig, - password: credentials.password, - key: credentials.sshKey, - keyPassword: credentials.keyPassword, - }, - }, - }), - ); - setShowAuthDialog(false); - setIsConnecting(true); - } - } - - function handleAuthDialogCancel() { - setShowAuthDialog(false); - if (onClose) onClose(); - } - - function scheduleNotify(cols: number, rows: number) { - if (!(cols > 0 && rows > 0)) return; - pendingSizeRef.current = { cols, rows }; - if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); - notifyTimerRef.current = setTimeout(() => { - const next = pendingSizeRef.current; - const last = lastSentSizeRef.current; - if (!next) return; - if (last && last.cols === next.cols && last.rows === next.rows) return; - if (webSocketRef.current?.readyState === WebSocket.OPEN) { - webSocketRef.current.send( - JSON.stringify({ type: "resize", data: next }), - ); - lastSentSizeRef.current = next; - } - }, DEBOUNCE_MS); - } - - useImperativeHandle( - ref, - () => ({ - disconnect: () => { - isUnmountingRef.current = true; - shouldNotReconnectRef.current = true; - isReconnectingRef.current = false; - if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current); - pingIntervalRef.current = null; - } - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - webSocketRef.current?.close(); - setIsConnected(false); - setIsConnecting(false); - }, - fit: () => { - fitAddonRef.current?.fit(); - if (terminal) scheduleNotify(terminal.cols, terminal.rows); - hardRefresh(); - }, - sendInput: (data: string) => { - if (webSocketRef.current?.readyState === 1) { - webSocketRef.current.send(JSON.stringify({ type: "input", data })); - } - }, - notifyResize: () => { - try { - const cols = terminal?.cols ?? undefined; - const rows = terminal?.rows ?? undefined; - if (typeof cols === "number" && typeof rows === "number") { - scheduleNotify(cols, rows); - hardRefresh(); - } - } catch (error) { - console.error("Terminal operation failed:", error); - } - }, - refresh: () => hardRefresh(), - }), - [terminal], - ); - - function getUseRightClickCopyPaste() { - return getCookie("rightClickCopyPaste") === "true"; - } - - function attemptReconnection() { - if ( - isUnmountingRef.current || - shouldNotReconnectRef.current || - isReconnectingRef.current || - isConnectingRef.current || - wasDisconnectedBySSH.current - ) { - return; - } - - if (reconnectAttempts.current >= maxReconnectAttempts) { - toast.error(t("terminal.maxReconnectAttemptsReached")); - if (onClose) { - onClose(); - } - return; - } - - isReconnectingRef.current = true; - - if (terminal) { - terminal.clear(); - } - - reconnectAttempts.current++; - - toast.info( - t("terminal.reconnecting", { - attempt: reconnectAttempts.current, - max: maxReconnectAttempts, - }), - ); - - reconnectTimeoutRef.current = setTimeout(() => { - if ( - isUnmountingRef.current || - shouldNotReconnectRef.current || - wasDisconnectedBySSH.current - ) { - isReconnectingRef.current = false; - return; - } - - if (reconnectAttempts.current > maxReconnectAttempts) { - isReconnectingRef.current = false; - return; - } - - const jwtToken = getCookie("jwt"); - if (!jwtToken || jwtToken.trim() === "") { - console.warn("Reconnection cancelled - no authentication token"); - isReconnectingRef.current = false; - setConnectionError("Authentication required for reconnection"); - return; - } - - if (terminal && hostConfig) { - terminal.clear(); - const cols = terminal.cols; - const rows = terminal.rows; - connectToHost(cols, rows); - } - - isReconnectingRef.current = false; - }, 2000 * reconnectAttempts.current); - } - - function connectToHost(cols: number, rows: number) { - if (isConnectingRef.current) { - return; - } - - isConnectingRef.current = true; - - const isDev = - !isElectron() && - process.env.NODE_ENV === "development" && - (window.location.port === "3000" || - window.location.port === "5173" || - window.location.port === ""); - - const jwtToken = getCookie("jwt"); - - if (!jwtToken || jwtToken.trim() === "") { - console.error("No JWT token available for WebSocket connection"); - setIsConnected(false); - setIsConnecting(false); - setConnectionError("Authentication required"); - isConnectingRef.current = false; - return; - } - - const baseWsUrl = isDev - ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002` - : isElectron() - ? (() => { - const baseUrl = - (window as { configuredServerUrl?: string }) - .configuredServerUrl || "http://127.0.0.1:30001"; - const wsProtocol = baseUrl.startsWith("https://") - ? "wss://" - : "ws://"; - const wsHost = baseUrl.replace(/^https?:\/\//, ""); - return `${wsProtocol}${wsHost}/ssh/websocket/`; - })() - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`; - - if ( - webSocketRef.current && - webSocketRef.current.readyState !== WebSocket.CLOSED - ) { - webSocketRef.current.close(); - } - - if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current); - pingIntervalRef.current = null; - } - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - - const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`; - - const ws = new WebSocket(wsUrl); - webSocketRef.current = ws; - wasDisconnectedBySSH.current = false; - setConnectionError(null); - shouldNotReconnectRef.current = false; - isReconnectingRef.current = false; - setIsConnecting(true); - - setupWebSocketListeners(ws, cols, rows); - } - - function setupWebSocketListeners( - ws: WebSocket, - cols: number, - rows: number, + function hardRefresh() { + try { + if ( + terminal && + typeof ( + terminal as { refresh?: (start: number, end: number) => void } + ).refresh === "function" ) { - ws.addEventListener("open", () => { - connectionTimeoutRef.current = setTimeout(() => { - if (!isConnected && !totpRequired && !isPasswordPrompt) { - if (terminal) { - terminal.clear(); - } - toast.error(t("terminal.connectionTimeout")); - if (webSocketRef.current) { - webSocketRef.current.close(); - } - if (reconnectAttempts.current > 0) { - attemptReconnection(); - } + ( + terminal as { refresh?: (start: number, end: number) => void } + ).refresh(0, terminal.rows - 1); + } + } catch (error) { + console.error("Terminal operation failed:", error); + } + } + + function performFit() { + if ( + !fitAddonRef.current || + !terminal || + !isVisibleRef.current || + isFittingRef.current + ) { + return; + } + + const lastSize = lastFittedSizeRef.current; + if ( + lastSize && + lastSize.cols === terminal.cols && + lastSize.rows === terminal.rows + ) { + return; + } + + isFittingRef.current = true; + + try { + fitAddonRef.current?.fit(); + if (terminal && terminal.cols > 0 && terminal.rows > 0) { + scheduleNotify(terminal.cols, terminal.rows); + lastFittedSizeRef.current = { + cols: terminal.cols, + rows: terminal.rows, + }; + } + setIsFitted(true); + } finally { + isFittingRef.current = false; + } + } + + function handleTotpSubmit(code: string) { + if (webSocketRef.current && code) { + webSocketRef.current.send( + JSON.stringify({ + type: isPasswordPrompt ? "password_response" : "totp_response", + data: { code }, + }), + ); + setTotpRequired(false); + setTotpPrompt(""); + setIsPasswordPrompt(false); + } + } + + function handleTotpCancel() { + setTotpRequired(false); + setTotpPrompt(""); + if (onClose) onClose(); + } + + function handleAuthDialogSubmit(credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) { + if (webSocketRef.current && terminal) { + webSocketRef.current.send( + JSON.stringify({ + type: "reconnect_with_credentials", + data: { + cols: terminal.cols, + rows: terminal.rows, + password: credentials.password, + sshKey: credentials.sshKey, + keyPassword: credentials.keyPassword, + hostConfig: { + ...hostConfig, + password: credentials.password, + key: credentials.sshKey, + keyPassword: credentials.keyPassword, + }, + }, + }), + ); + setShowAuthDialog(false); + setIsConnecting(true); + } + } + + function handleAuthDialogCancel() { + setShowAuthDialog(false); + if (onClose) onClose(); + } + + function scheduleNotify(cols: number, rows: number) { + if (!(cols > 0 && rows > 0)) return; + pendingSizeRef.current = { cols, rows }; + if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); + notifyTimerRef.current = setTimeout(() => { + const next = pendingSizeRef.current; + const last = lastSentSizeRef.current; + if (!next) return; + if (last && last.cols === next.cols && last.rows === next.rows) return; + if (webSocketRef.current?.readyState === WebSocket.OPEN) { + webSocketRef.current.send( + JSON.stringify({ type: "resize", data: next }), + ); + lastSentSizeRef.current = next; + } + }, DEBOUNCE_MS); + } + + useImperativeHandle( + ref, + () => ({ + disconnect: () => { + isUnmountingRef.current = true; + shouldNotReconnectRef.current = true; + isReconnectingRef.current = false; + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + webSocketRef.current?.close(); + setIsConnected(false); + setIsConnecting(false); + }, + fit: () => { + fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + }, + sendInput: (data: string) => { + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send(JSON.stringify({ type: "input", data })); + } + }, + notifyResize: () => { + try { + const cols = terminal?.cols ?? undefined; + const rows = terminal?.rows ?? undefined; + if (typeof cols === "number" && typeof rows === "number") { + scheduleNotify(cols, rows); + hardRefresh(); + } + } catch (error) { + console.error("Terminal operation failed:", error); + } + }, + refresh: () => hardRefresh(), + }), + [terminal], + ); + + function getUseRightClickCopyPaste() { + return getCookie("rightClickCopyPaste") === "true"; + } + + function attemptReconnection() { + if ( + isUnmountingRef.current || + shouldNotReconnectRef.current || + isReconnectingRef.current || + isConnectingRef.current || + wasDisconnectedBySSH.current + ) { + return; + } + + if (reconnectAttempts.current >= maxReconnectAttempts) { + toast.error(t("terminal.maxReconnectAttemptsReached")); + if (onClose) { + onClose(); + } + return; + } + + isReconnectingRef.current = true; + + if (terminal) { + terminal.clear(); + } + + reconnectAttempts.current++; + + toast.info( + t("terminal.reconnecting", { + attempt: reconnectAttempts.current, + max: maxReconnectAttempts, + }), + ); + + reconnectTimeoutRef.current = setTimeout(() => { + if ( + isUnmountingRef.current || + shouldNotReconnectRef.current || + wasDisconnectedBySSH.current + ) { + isReconnectingRef.current = false; + return; + } + + if (reconnectAttempts.current > maxReconnectAttempts) { + isReconnectingRef.current = false; + return; + } + + const jwtToken = getCookie("jwt"); + if (!jwtToken || jwtToken.trim() === "") { + console.warn("Reconnection cancelled - no authentication token"); + isReconnectingRef.current = false; + setConnectionError("Authentication required for reconnection"); + return; + } + + if (terminal && hostConfig) { + terminal.clear(); + const cols = terminal.cols; + const rows = terminal.rows; + connectToHost(cols, rows); + } + + isReconnectingRef.current = false; + }, 2000 * reconnectAttempts.current); + } + + function connectToHost(cols: number, rows: number) { + if (isConnectingRef.current) { + return; + } + + isConnectingRef.current = true; + + const isDev = + !isElectron() && + process.env.NODE_ENV === "development" && + (window.location.port === "3000" || + window.location.port === "5173" || + window.location.port === ""); + + const jwtToken = getCookie("jwt"); + + if (!jwtToken || jwtToken.trim() === "") { + console.error("No JWT token available for WebSocket connection"); + setIsConnected(false); + setIsConnecting(false); + setConnectionError("Authentication required"); + isConnectingRef.current = false; + return; + } + + const baseWsUrl = isDev + ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002` + : isElectron() + ? (() => { + const baseUrl = + (window as { configuredServerUrl?: string }) + .configuredServerUrl || "http://127.0.0.1:30001"; + const wsProtocol = baseUrl.startsWith("https://") + ? "wss://" + : "ws://"; + const wsHost = baseUrl.replace(/^https?:\/\//, ""); + return `${wsProtocol}${wsHost}/ssh/websocket/`; + })() + : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`; + + if ( + webSocketRef.current && + webSocketRef.current.readyState !== WebSocket.CLOSED + ) { + webSocketRef.current.close(); + } + + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + + const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`; + + const ws = new WebSocket(wsUrl); + webSocketRef.current = ws; + wasDisconnectedBySSH.current = false; + setConnectionError(null); + shouldNotReconnectRef.current = false; + isReconnectingRef.current = false; + setIsConnecting(true); + + setupWebSocketListeners(ws, cols, rows); + } + + function setupWebSocketListeners( + ws: WebSocket, + cols: number, + rows: number, + ) { + ws.addEventListener("open", () => { + connectionTimeoutRef.current = setTimeout(() => { + if (!isConnected && !totpRequired && !isPasswordPrompt) { + if (terminal) { + terminal.clear(); + } + toast.error(t("terminal.connectionTimeout")); + if (webSocketRef.current) { + webSocketRef.current.close(); + } + if (reconnectAttempts.current > 0) { + attemptReconnection(); + } + } + }, 10000); + + ws.send( + JSON.stringify({ + type: "connectToHost", + data: { cols, rows, hostConfig, initialPath, executeCommand }, + }), + ); + terminal.onData((data) => { + trackInput(data); + ws.send(JSON.stringify({ type: "input", data })); + }); + + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ping" })); + } + }, 30000); + }); + + ws.addEventListener("message", (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === "data") { + if (typeof msg.data === "string") { + terminal.write(msg.data); + // Sudo password prompt detection + const sudoPasswordPattern = + /(?:\[sudo\] password for \S+:|sudo: a password is required)/; + if ( + config.sudoPasswordAutoFill && + sudoPasswordPattern.test(msg.data) && + hostConfig.password && + !sudoPromptShownRef.current + ) { + sudoPromptShownRef.current = true; + confirmWithToast( + t("terminal.sudoPasswordPopupTitle", "Insert password?"), + async () => { + if ( + webSocketRef.current && + webSocketRef.current.readyState === WebSocket.OPEN + ) { + webSocketRef.current.send( + JSON.stringify({ + type: "input", + data: hostConfig.password + "\n", + }), + ); } - }, 10000); - - ws.send( - JSON.stringify({ - type: "connectToHost", - data: { cols, rows, hostConfig, initialPath, executeCommand }, - }), - ); - terminal.onData((data) => { - trackInput(data); - ws.send(JSON.stringify({ type: "input", data })); - }); - - pingIntervalRef.current = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "ping" })); - } - }, 30000); - }); - - ws.addEventListener("message", (event) => { - try { - const msg = JSON.parse(event.data); - if (msg.type === "data") { - if (typeof msg.data === "string") { - terminal.write(msg.data); - // Sudo password prompt detection - const sudoPasswordPattern = /(?:\[sudo\] password for \S+:|sudo: a password is required)/; - if (config.sudoPasswordAutoFill && sudoPasswordPattern.test(msg.data)) { - setShowSudoPasswordPopup(true); - } - } else { - terminal.write(String(msg.data)); - } - } else if (msg.type === "error") { - const errorMessage = msg.message || t("terminal.unknownError"); - - if ( - errorMessage.toLowerCase().includes("connection") || - errorMessage.toLowerCase().includes("timeout") || - errorMessage.toLowerCase().includes("network") - ) { - toast.error( - t("terminal.connectionError", { message: errorMessage }), - ); - setIsConnected(false); - if (terminal) { - terminal.clear(); - } - setIsConnecting(true); - wasDisconnectedBySSH.current = false; - attemptReconnection(); - return; - } - - if ( - (errorMessage.toLowerCase().includes("auth") && - errorMessage.toLowerCase().includes("failed")) || - errorMessage.toLowerCase().includes("permission denied") || - (errorMessage.toLowerCase().includes("invalid") && - (errorMessage.toLowerCase().includes("password") || - errorMessage.toLowerCase().includes("key"))) || - errorMessage.toLowerCase().includes("incorrect password") - ) { - toast.error(t("terminal.authError", { message: errorMessage })); - shouldNotReconnectRef.current = true; - if (webSocketRef.current) { - webSocketRef.current.close(); - } - if (onClose) { - onClose(); - } - return; - } - - toast.error(t("terminal.error", { message: errorMessage })); - } else if (msg.type === "connected") { - setIsConnected(true); - setIsConnecting(false); - isConnectingRef.current = false; - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - if (reconnectAttempts.current > 0) { - toast.success(t("terminal.reconnected")); - } - reconnectAttempts.current = 0; - isReconnectingRef.current = false; - - logTerminalActivity(); - - setTimeout(async () => { - const terminalConfig = { - ...DEFAULT_TERMINAL_CONFIG, - ...hostConfig.terminalConfig, - }; - - if ( - terminalConfig.environmentVariables && - terminalConfig.environmentVariables.length > 0 - ) { - for (const envVar of terminalConfig.environmentVariables) { - if (envVar.key && envVar.value && ws.readyState === 1) { - ws.send( - JSON.stringify({ - type: "input", - data: `export ${envVar.key}="${envVar.value}"\n`, - }), - ); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - } - - if (terminalConfig.startupSnippetId) { - try { - const snippets = await getSnippets(); - const snippet = snippets.find( - (s: { id: number }) => - s.id === terminalConfig.startupSnippetId, - ); - if (snippet && ws.readyState === 1) { - ws.send( - JSON.stringify({ - type: "input", - data: snippet.content + "\n", - }), - ); - await new Promise((resolve) => setTimeout(resolve, 200)); - } - } catch (err) { - console.warn("Failed to execute startup snippet:", err); - } - } - - if (terminalConfig.autoMosh && ws.readyState === 1) { - ws.send( - JSON.stringify({ - type: "input", - data: terminalConfig.moshCommand + "\n", - }), - ); - } - }, 500); - } else if (msg.type === "disconnected") { - wasDisconnectedBySSH.current = true; - setIsConnected(false); - if (terminal) { - terminal.clear(); - } - setIsConnecting(false); - if (onClose) { - onClose(); - } - } else if (msg.type === "totp_required") { - setTotpRequired(true); - setTotpPrompt(msg.prompt || "Verification code:"); - setIsPasswordPrompt(false); - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - } else if (msg.type === "password_required") { - setTotpRequired(true); - setTotpPrompt(msg.prompt || "Password:"); - setIsPasswordPrompt(true); - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - } else if (msg.type === "keyboard_interactive_available") { - setKeyboardInteractiveDetected(true); - setIsConnecting(false); - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - } else if (msg.type === "auth_method_not_available") { - setAuthDialogReason("no_keyboard"); - setShowAuthDialog(true); - setIsConnecting(false); - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - } - } catch { - toast.error(t("terminal.messageParseError")); - } - }); - - ws.addEventListener("close", (event) => { - setIsConnected(false); - isConnectingRef.current = false; - if (terminal) { - terminal.clear(); - } - - if (event.code === 1008) { - console.error("WebSocket authentication failed:", event.reason); - setConnectionError("Authentication failed - please re-login"); - setIsConnecting(false); - shouldNotReconnectRef.current = true; - - localStorage.removeItem("jwt"); - setTimeout(() => { - window.location.reload(); - }, 1000); - - return; - } - - setIsConnecting(false); - if ( - !wasDisconnectedBySSH.current && - !isUnmountingRef.current && - !shouldNotReconnectRef.current - ) { - wasDisconnectedBySSH.current = false; - attemptReconnection(); - } - }); - - ws.addEventListener("error", () => { - setIsConnected(false); - isConnectingRef.current = false; - setConnectionError(t("terminal.websocketError")); - if (terminal) { - terminal.clear(); - } - setIsConnecting(false); - if (!isUnmountingRef.current && !shouldNotReconnectRef.current) { - wasDisconnectedBySSH.current = false; - attemptReconnection(); - } - }); - } - - async function writeTextToClipboard(text: string): Promise { - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - return; - } - } catch (error) { - console.error("Terminal operation failed:", error); - } - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.left = "-9999px"; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - try { - document.execCommand("copy"); - } finally { - document.body.removeChild(textarea); - } - } - - async function readTextFromClipboard(): Promise { - try { - if (navigator.clipboard && navigator.clipboard.readText) { - return await navigator.clipboard.readText(); - } - } catch (error) { - console.error("Terminal operation failed:", error); - } - return ""; - } - - const handleSelectCommand = useCallback( - (command: string) => { - if (!terminal || !webSocketRef.current) return; - - for (const char of command) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: char }), - ); - } - + sudoPromptShownRef.current = false; + }, 3000); + }, + ); setTimeout(() => { - terminal.focus(); - }, 100); - }, - [terminal], - ); + sudoPromptShownRef.current = false; + }, 15000); + } + } else { + terminal.write(String(msg.data)); + } + } else if (msg.type === "error") { + const errorMessage = msg.message || t("terminal.unknownError"); - useEffect(() => { - commandHistoryContext.setOnSelectCommand(handleSelectCommand); - }, [handleSelectCommand]); + if ( + errorMessage.toLowerCase().includes("connection") || + errorMessage.toLowerCase().includes("timeout") || + errorMessage.toLowerCase().includes("network") + ) { + toast.error( + t("terminal.connectionError", { message: errorMessage }), + ); + setIsConnected(false); + if (terminal) { + terminal.clear(); + } + setIsConnecting(true); + wasDisconnectedBySSH.current = false; + attemptReconnection(); + return; + } - const handleAutocompleteSelect = useCallback( - (selectedCommand: string) => { - if (!webSocketRef.current) return; + if ( + (errorMessage.toLowerCase().includes("auth") && + errorMessage.toLowerCase().includes("failed")) || + errorMessage.toLowerCase().includes("permission denied") || + (errorMessage.toLowerCase().includes("invalid") && + (errorMessage.toLowerCase().includes("password") || + errorMessage.toLowerCase().includes("key"))) || + errorMessage.toLowerCase().includes("incorrect password") + ) { + toast.error(t("terminal.authError", { message: errorMessage })); + shouldNotReconnectRef.current = true; + if (webSocketRef.current) { + webSocketRef.current.close(); + } + if (onClose) { + onClose(); + } + return; + } - const currentCmd = currentAutocompleteCommand.current; - const completion = selectedCommand.substring(currentCmd.length); + toast.error(t("terminal.error", { message: errorMessage })); + } else if (msg.type === "connected") { + setIsConnected(true); + setIsConnecting(false); + isConnectingRef.current = false; + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + if (reconnectAttempts.current > 0) { + toast.success(t("terminal.reconnected")); + } + reconnectAttempts.current = 0; + isReconnectingRef.current = false; - for (const char of completion) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: char }), - ); - } + logTerminalActivity(); - updateCurrentCommand(selectedCommand); - - setShowAutocomplete(false); - setAutocompleteSuggestions([]); - currentAutocompleteCommand.current = ""; - - setTimeout(() => { - terminal?.focus(); - }, 50); - - console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`); - }, - [terminal, updateCurrentCommand], - ); - - const handleDeleteCommand = useCallback( - async (command: string) => { - if (!hostConfig.id) return; - - try { - const { deleteCommandFromHistory } = await import( - "@/ui/main-axios.ts" - ); - await deleteCommandFromHistory(hostConfig.id, command); - - setCommandHistory((prev) => { - const newHistory = prev.filter((cmd) => cmd !== command); - setCommandHistoryContextRef.current(newHistory); - return newHistory; - }); - - autocompleteHistory.current = autocompleteHistory.current.filter( - (cmd) => cmd !== command, - ); - - console.log(`[Terminal] Command deleted from history: ${command}`); - } catch (error) { - console.error("Failed to delete command from history:", error); - } - }, - [hostConfig.id], - ); - - useEffect(() => { - commandHistoryContext.setOnDeleteCommand(handleDeleteCommand); - }, [handleDeleteCommand]); - - useEffect(() => { - if (!terminal || !xtermRef.current) return; - - const config = { + setTimeout(async () => { + const terminalConfig = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig, - }; + }; - const themeColors = - TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors; + if ( + terminalConfig.environmentVariables && + terminalConfig.environmentVariables.length > 0 + ) { + for (const envVar of terminalConfig.environmentVariables) { + if (envVar.key && envVar.value && ws.readyState === 1) { + ws.send( + JSON.stringify({ + type: "input", + data: `export ${envVar.key}="${envVar.value}"\n`, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + } - const fontConfig = TERMINAL_FONTS.find( - (f) => f.value === config.fontFamily, - ); - const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; - - terminal.options = { - cursorBlink: config.cursorBlink, - cursorStyle: config.cursorStyle, - scrollback: config.scrollback, - fontSize: config.fontSize, - fontFamily, - allowTransparency: true, - convertEol: true, - windowsMode: false, - macOptionIsMeta: false, - macOptionClickForcesSelection: false, - rightClickSelectsWord: config.rightClickSelectsWord, - fastScrollModifier: config.fastScrollModifier, - fastScrollSensitivity: config.fastScrollSensitivity, - allowProposedApi: true, - minimumContrastRatio: config.minimumContrastRatio, - letterSpacing: config.letterSpacing, - lineHeight: config.lineHeight, - bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both", - - theme: { - background: themeColors.background, - foreground: themeColors.foreground, - cursor: themeColors.cursor, - cursorAccent: themeColors.cursorAccent, - selectionBackground: themeColors.selectionBackground, - selectionForeground: themeColors.selectionForeground, - black: themeColors.black, - red: themeColors.red, - green: themeColors.green, - yellow: themeColors.yellow, - blue: themeColors.blue, - magenta: themeColors.magenta, - cyan: themeColors.cyan, - white: themeColors.white, - brightBlack: themeColors.brightBlack, - brightRed: themeColors.brightRed, - brightGreen: themeColors.brightGreen, - brightYellow: themeColors.brightYellow, - brightBlue: themeColors.brightBlue, - brightMagenta: themeColors.brightMagenta, - brightCyan: themeColors.brightCyan, - brightWhite: themeColors.brightWhite, - }, - }; - - const fitAddon = new FitAddon(); - const clipboardAddon = new ClipboardAddon(); - const unicode11Addon = new Unicode11Addon(); - const webLinksAddon = new WebLinksAddon(); - - fitAddonRef.current = fitAddon; - terminal.loadAddon(fitAddon); - terminal.loadAddon(clipboardAddon); - terminal.loadAddon(unicode11Addon); - terminal.loadAddon(webLinksAddon); - - terminal.unicode.activeVersion = "11"; - - terminal.open(xtermRef.current); - - const element = xtermRef.current; - const handleContextMenu = async (e: MouseEvent) => { - if (!getUseRightClickCopyPaste()) return; - e.preventDefault(); - e.stopPropagation(); + if (terminalConfig.startupSnippetId) { try { - if (terminal.hasSelection()) { - const selection = terminal.getSelection(); - if (selection) { - await writeTextToClipboard(selection); - terminal.clearSelection(); - } - } else { - const pasteText = await readTextFromClipboard(); - if (pasteText) terminal.paste(pasteText); - } - } catch (error) { - console.error("Terminal operation failed:", error); + const snippets = await getSnippets(); + const snippet = snippets.find( + (s: { id: number }) => + s.id === terminalConfig.startupSnippetId, + ); + if (snippet && ws.readyState === 1) { + ws.send( + JSON.stringify({ + type: "input", + data: snippet.content + "\n", + }), + ); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } catch (err) { + console.warn("Failed to execute startup snippet:", err); } - }; - element?.addEventListener("contextmenu", handleContextMenu); + } - const handleMacKeyboard = (e: KeyboardEvent) => { - const isMacOS = - navigator.platform.toUpperCase().indexOf("MAC") >= 0 || - navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; + if (terminalConfig.autoMosh && ws.readyState === 1) { + ws.send( + JSON.stringify({ + type: "input", + data: terminalConfig.moshCommand + "\n", + }), + ); + } + }, 500); + } else if (msg.type === "disconnected") { + wasDisconnectedBySSH.current = true; + setIsConnected(false); + if (terminal) { + terminal.clear(); + } + setIsConnecting(false); + if (onClose) { + onClose(); + } + } else if (msg.type === "totp_required") { + setTotpRequired(true); + setTotpPrompt(msg.prompt || "Verification code:"); + setIsPasswordPrompt(false); + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + } else if (msg.type === "password_required") { + setTotpRequired(true); + setTotpPrompt(msg.prompt || "Password:"); + setIsPasswordPrompt(true); + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + } else if (msg.type === "keyboard_interactive_available") { + setKeyboardInteractiveDetected(true); + setIsConnecting(false); + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + } else if (msg.type === "auth_method_not_available") { + setAuthDialogReason("no_keyboard"); + setShowAuthDialog(true); + setIsConnecting(false); + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + } + } catch { + toast.error(t("terminal.messageParseError")); + } + }); - if ( - e.ctrlKey && - e.key === "r" && - !e.shiftKey && - !e.altKey && - !e.metaKey - ) { - e.preventDefault(); - e.stopPropagation(); - setShowHistoryDialog(true); - if (commandHistoryContext.openCommandHistory) { - commandHistoryContext.openCommandHistory(); - } - return false; - } + ws.addEventListener("close", (event) => { + setIsConnected(false); + isConnectingRef.current = false; + if (terminal) { + terminal.clear(); + } - if ( - config.backspaceMode === "control-h" && - e.key === "Backspace" && - !e.ctrlKey && - !e.metaKey && - !e.altKey - ) { - e.preventDefault(); - e.stopPropagation(); - if (webSocketRef.current?.readyState === 1) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: "\x08" }), - ); - } - return false; - } + if (event.code === 1008) { + console.error("WebSocket authentication failed:", event.reason); + setConnectionError("Authentication failed - please re-login"); + setIsConnecting(false); + shouldNotReconnectRef.current = true; - if (!isMacOS) return; + localStorage.removeItem("jwt"); - if (e.altKey && !e.metaKey && !e.ctrlKey) { - const keyMappings: { [key: string]: string } = { - "7": "|", - "2": "€", - "8": "[", - "9": "]", - l: "@", - L: "@", - Digit7: "|", - Digit2: "€", - Digit8: "[", - Digit9: "]", - KeyL: "@", - }; + setTimeout(() => { + window.location.reload(); + }, 1000); - const char = keyMappings[e.key] || keyMappings[e.code]; - if (char) { - e.preventDefault(); - e.stopPropagation(); + return; + } - if (webSocketRef.current?.readyState === 1) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: char }), - ); - } - return false; - } - } - }; + setIsConnecting(false); + if ( + !wasDisconnectedBySSH.current && + !isUnmountingRef.current && + !shouldNotReconnectRef.current + ) { + wasDisconnectedBySSH.current = false; + attemptReconnection(); + } + }); - element?.addEventListener("keydown", handleMacKeyboard, true); + ws.addEventListener("error", () => { + setIsConnected(false); + isConnectingRef.current = false; + setConnectionError(t("terminal.websocketError")); + if (terminal) { + terminal.clear(); + } + setIsConnecting(false); + if (!isUnmountingRef.current && !shouldNotReconnectRef.current) { + wasDisconnectedBySSH.current = false; + attemptReconnection(); + } + }); + } - const resizeObserver = new ResizeObserver(() => { - if (resizeTimeout.current) clearTimeout(resizeTimeout.current); - resizeTimeout.current = setTimeout(() => { - if (!isVisibleRef.current || !isReady) return; - performFit(); - }, 50); - }); + async function writeTextToClipboard(text: string): Promise { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return; + } + } catch (error) { + console.error("Terminal operation failed:", error); + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } + } - resizeObserver.observe(xtermRef.current); + async function readTextFromClipboard(): Promise { + try { + if (navigator.clipboard && navigator.clipboard.readText) { + return await navigator.clipboard.readText(); + } + } catch (error) { + console.error("Terminal operation failed:", error); + } + return ""; + } - setVisible(true); + const handleSelectCommand = useCallback( + (command: string) => { + if (!terminal || !webSocketRef.current) return; - return () => { - isUnmountingRef.current = true; - shouldNotReconnectRef.current = true; - isReconnectingRef.current = false; - setIsConnecting(false); - setVisible(false); - setIsReady(false); - isFittingRef.current = false; - resizeObserver.disconnect(); - element?.removeEventListener("contextmenu", handleContextMenu); - element?.removeEventListener("keydown", handleMacKeyboard, true); - if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); - if (resizeTimeout.current) clearTimeout(resizeTimeout.current); - if (reconnectTimeoutRef.current) - clearTimeout(reconnectTimeoutRef.current); - if (connectionTimeoutRef.current) - clearTimeout(connectionTimeoutRef.current); - if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current); - pingIntervalRef.current = null; - } - webSocketRef.current?.close(); - }; - }, [xtermRef, terminal, hostConfig]); + for (const char of command) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } - useEffect(() => { - if (!terminal) return; + setTimeout(() => { + terminal.focus(); + }, 100); + }, + [terminal], + ); - const handleCustomKey = (e: KeyboardEvent): boolean => { - if (e.type !== "keydown") { - return true; - } + useEffect(() => { + commandHistoryContext.setOnSelectCommand(handleSelectCommand); + }, [handleSelectCommand]); - if (showAutocompleteRef.current) { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - setShowAutocomplete(false); - setAutocompleteSuggestions([]); - currentAutocompleteCommand.current = ""; - return false; - } + const handleAutocompleteSelect = useCallback( + (selectedCommand: string) => { + if (!webSocketRef.current) return; - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); - const currentIndex = autocompleteSelectedIndexRef.current; - const suggestionsLength = autocompleteSuggestionsRef.current.length; + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } - if (e.key === "ArrowDown") { - const newIndex = - currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; - setAutocompleteSelectedIndex(newIndex); - } else if (e.key === "ArrowUp") { - const newIndex = - currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1; - setAutocompleteSelectedIndex(newIndex); - } - return false; - } + updateCurrentCommand(selectedCommand); - if ( - e.key === "Enter" && - autocompleteSuggestionsRef.current.length > 0 - ) { - e.preventDefault(); - e.stopPropagation(); + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; - const selectedCommand = - autocompleteSuggestionsRef.current[ - autocompleteSelectedIndexRef.current - ]; - const currentCmd = currentAutocompleteCommand.current; - const completion = selectedCommand.substring(currentCmd.length); + setTimeout(() => { + terminal?.focus(); + }, 50); - if (webSocketRef.current?.readyState === 1) { - for (const char of completion) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: char }), - ); - } - } + console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`); + }, + [terminal, updateCurrentCommand], + ); - updateCurrentCommandRef.current(selectedCommand); + const handleDeleteCommand = useCallback( + async (command: string) => { + if (!hostConfig.id) return; - setShowAutocomplete(false); - setAutocompleteSuggestions([]); - currentAutocompleteCommand.current = ""; + try { + const { deleteCommandFromHistory } = + await import("@/ui/main-axios.ts"); + await deleteCommandFromHistory(hostConfig.id, command); - return false; - } + setCommandHistory((prev) => { + const newHistory = prev.filter((cmd) => cmd !== command); + setCommandHistoryContextRef.current(newHistory); + return newHistory; + }); - if ( - e.key === "Tab" && - !e.ctrlKey && - !e.altKey && - !e.metaKey && - !e.shiftKey - ) { - e.preventDefault(); - e.stopPropagation(); - const currentIndex = autocompleteSelectedIndexRef.current; - const suggestionsLength = autocompleteSuggestionsRef.current.length; - const newIndex = - currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; - setAutocompleteSelectedIndex(newIndex); - return false; - } + autocompleteHistory.current = autocompleteHistory.current.filter( + (cmd) => cmd !== command, + ); - setShowAutocomplete(false); - setAutocompleteSuggestions([]); - currentAutocompleteCommand.current = ""; - return true; - } + console.log(`[Terminal] Command deleted from history: ${command}`); + } catch (error) { + console.error("Failed to delete command from history:", error); + } + }, + [hostConfig.id], + ); - if ( - e.key === "Tab" && - !e.ctrlKey && - !e.altKey && - !e.metaKey && - !e.shiftKey - ) { - e.preventDefault(); - e.stopPropagation(); + useEffect(() => { + commandHistoryContext.setOnDeleteCommand(handleDeleteCommand); + }, [handleDeleteCommand]); - const autocompleteEnabled = - localStorage.getItem("commandAutocomplete") !== "false"; + useEffect(() => { + if (!terminal || !xtermRef.current) return; - if (!autocompleteEnabled) { - if (webSocketRef.current?.readyState === 1) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: "\t" }), - ); - } - return false; - } + const config = { + ...DEFAULT_TERMINAL_CONFIG, + ...hostConfig.terminalConfig, + }; - const currentCmd = getCurrentCommandRef.current().trim(); - if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) { - const matches = autocompleteHistory.current - .filter( - (cmd) => - cmd.startsWith(currentCmd) && - cmd !== currentCmd && - cmd.length > currentCmd.length, - ) - .slice(0, 5); + const themeColors = + TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors; - if (matches.length === 1) { - const completedCommand = matches[0]; - const completion = completedCommand.substring(currentCmd.length); + const fontConfig = TERMINAL_FONTS.find( + (f) => f.value === config.fontFamily, + ); + const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; - for (const char of completion) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: char }), - ); - } + terminal.options = { + cursorBlink: config.cursorBlink, + cursorStyle: config.cursorStyle, + scrollback: config.scrollback, + fontSize: config.fontSize, + fontFamily, + allowTransparency: true, + convertEol: true, + windowsMode: false, + macOptionIsMeta: false, + macOptionClickForcesSelection: false, + rightClickSelectsWord: config.rightClickSelectsWord, + fastScrollModifier: config.fastScrollModifier, + fastScrollSensitivity: config.fastScrollSensitivity, + allowProposedApi: true, + minimumContrastRatio: config.minimumContrastRatio, + letterSpacing: config.letterSpacing, + lineHeight: config.lineHeight, + bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both", - updateCurrentCommandRef.current(completedCommand); - } else if (matches.length > 1) { - currentAutocompleteCommand.current = currentCmd; - setAutocompleteSuggestions(matches); - setAutocompleteSelectedIndex(0); + theme: { + background: themeColors.background, + foreground: themeColors.foreground, + cursor: themeColors.cursor, + cursorAccent: themeColors.cursorAccent, + selectionBackground: themeColors.selectionBackground, + selectionForeground: themeColors.selectionForeground, + black: themeColors.black, + red: themeColors.red, + green: themeColors.green, + yellow: themeColors.yellow, + blue: themeColors.blue, + magenta: themeColors.magenta, + cyan: themeColors.cyan, + white: themeColors.white, + brightBlack: themeColors.brightBlack, + brightRed: themeColors.brightRed, + brightGreen: themeColors.brightGreen, + brightYellow: themeColors.brightYellow, + brightBlue: themeColors.brightBlue, + brightMagenta: themeColors.brightMagenta, + brightCyan: themeColors.brightCyan, + brightWhite: themeColors.brightWhite, + }, + }; - const cursorY = terminal.buffer.active.cursorY; - const cursorX = terminal.buffer.active.cursorX; - const rect = xtermRef.current?.getBoundingClientRect(); + const fitAddon = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + const unicode11Addon = new Unicode11Addon(); + const webLinksAddon = new WebLinksAddon(); - if (rect) { - const cellHeight = - terminal.rows > 0 ? rect.height / terminal.rows : 20; - const cellWidth = - terminal.cols > 0 ? rect.width / terminal.cols : 10; + fitAddonRef.current = fitAddon; + terminal.loadAddon(fitAddon); + terminal.loadAddon(clipboardAddon); + terminal.loadAddon(unicode11Addon); + terminal.loadAddon(webLinksAddon); - const itemHeight = 32; - const footerHeight = 32; - const maxMenuHeight = 240; - const estimatedMenuHeight = Math.min( - matches.length * itemHeight + footerHeight, - maxMenuHeight, - ); - const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; - const cursorTopY = rect.top + cursorY * cellHeight; - const spaceBelow = window.innerHeight - cursorBottomY; - const spaceAbove = cursorTopY; + terminal.unicode.activeVersion = "11"; - const showAbove = - spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow; + terminal.open(xtermRef.current); - setAutocompletePosition({ - top: showAbove - ? Math.max(0, cursorTopY - estimatedMenuHeight) - : cursorBottomY, - left: Math.max(0, rect.left + cursorX * cellWidth), - }); - } + const element = xtermRef.current; + const handleContextMenu = async (e: MouseEvent) => { + if (!getUseRightClickCopyPaste()) return; + e.preventDefault(); + e.stopPropagation(); + try { + if (terminal.hasSelection()) { + const selection = terminal.getSelection(); + if (selection) { + await writeTextToClipboard(selection); + terminal.clearSelection(); + } + } else { + const pasteText = await readTextFromClipboard(); + if (pasteText) terminal.paste(pasteText); + } + } catch (error) { + console.error("Terminal operation failed:", error); + } + }; + element?.addEventListener("contextmenu", handleContextMenu); - setShowAutocomplete(true); - } - } - return false; - } + const handleMacKeyboard = (e: KeyboardEvent) => { + const isMacOS = + navigator.platform.toUpperCase().indexOf("MAC") >= 0 || + navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; - return true; - }; + if ( + config.backspaceMode === "control-h" && + e.key === "Backspace" && + !e.ctrlKey && + !e.metaKey && + !e.altKey + ) { + e.preventDefault(); + e.stopPropagation(); + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: "\x08" }), + ); + } + return false; + } - terminal.attachCustomKeyEventHandler(handleCustomKey); - }, [terminal]); + if (!isMacOS) return; - useEffect(() => { - if (!terminal || !hostConfig || !visible) return; + if (e.altKey && !e.metaKey && !e.ctrlKey) { + const keyMappings: { [key: string]: string } = { + "7": "|", + "2": "€", + "8": "[", + "9": "]", + l: "@", + L: "@", + Digit7: "|", + Digit2: "€", + Digit8: "[", + Digit9: "]", + KeyL: "@", + }; - if (isConnected || isConnecting) return; + const char = keyMappings[e.key] || keyMappings[e.code]; + if (char) { + e.preventDefault(); + e.stopPropagation(); - setIsConnecting(true); + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } + return false; + } + } + }; - const readyFonts = - (document as { fonts?: { ready?: Promise } }).fonts - ?.ready instanceof Promise - ? (document as { fonts?: { ready?: Promise } }).fonts.ready - : Promise.resolve(); + element?.addEventListener("keydown", handleMacKeyboard, true); - readyFonts.then(() => { - requestAnimationFrame(() => { - fitAddonRef.current?.fit(); - if (terminal && terminal.cols > 0 && terminal.rows > 0) { - scheduleNotify(terminal.cols, terminal.rows); - } - hardRefresh(); + const resizeObserver = new ResizeObserver(() => { + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + resizeTimeout.current = setTimeout(() => { + if (!isVisibleRef.current || !isReady) return; + performFit(); + }, 50); + }); - setVisible(true); - setIsReady(true); + resizeObserver.observe(xtermRef.current); - if (terminal && !splitScreen) { - terminal.focus(); - } + setVisible(true); - const jwtToken = getCookie("jwt"); + return () => { + isUnmountingRef.current = true; + shouldNotReconnectRef.current = true; + isReconnectingRef.current = false; + setIsConnecting(false); + setVisible(false); + setIsReady(false); + isFittingRef.current = false; + resizeObserver.disconnect(); + element?.removeEventListener("contextmenu", handleContextMenu); + element?.removeEventListener("keydown", handleMacKeyboard, true); + if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + if (reconnectTimeoutRef.current) + clearTimeout(reconnectTimeoutRef.current); + if (connectionTimeoutRef.current) + clearTimeout(connectionTimeoutRef.current); + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + webSocketRef.current?.close(); + }; + }, [xtermRef, terminal, hostConfig]); - if (!jwtToken || jwtToken.trim() === "") { - setIsConnected(false); - setIsConnecting(false); - setConnectionError("Authentication required"); - return; - } + useEffect(() => { + if (!terminal) return; - const cols = terminal.cols; - const rows = terminal.rows; + const handleCustomKey = (e: KeyboardEvent): boolean => { + if (e.type !== "keydown") { + return true; + } - connectToHost(cols, rows); - }); - }); - }, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]); + if (showAutocompleteRef.current) { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return false; + } - useEffect(() => { - if (!isVisible || !isReady || !fitAddonRef.current || !terminal) { - return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + + const currentIndex = autocompleteSelectedIndexRef.current; + const suggestionsLength = autocompleteSuggestionsRef.current.length; + + if (e.key === "ArrowDown") { + const newIndex = + currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; + setAutocompleteSelectedIndex(newIndex); + } else if (e.key === "ArrowUp") { + const newIndex = + currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1; + setAutocompleteSelectedIndex(newIndex); + } + return false; + } + + if ( + e.key === "Enter" && + autocompleteSuggestionsRef.current.length > 0 + ) { + e.preventDefault(); + e.stopPropagation(); + + const selectedCommand = + autocompleteSuggestionsRef.current[ + autocompleteSelectedIndexRef.current + ]; + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); + + if (webSocketRef.current?.readyState === 1) { + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } } - let rafId: number; + updateCurrentCommandRef.current(selectedCommand); - rafId = requestAnimationFrame(() => { - performFit(); - }); + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; - return () => { - if (rafId) cancelAnimationFrame(rafId); - }; - }, [isVisible, isReady, splitScreen, terminal]); + return false; + } - useEffect(() => { - if ( - isFitted && - isVisible && - isReady && - !isConnecting && - terminal && - !splitScreen - ) { - const rafId = requestAnimationFrame(() => { - terminal.focus(); - }); - return () => cancelAnimationFrame(rafId); + if ( + e.key === "Tab" && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey + ) { + e.preventDefault(); + e.stopPropagation(); + const currentIndex = autocompleteSelectedIndexRef.current; + const suggestionsLength = autocompleteSuggestionsRef.current.length; + const newIndex = + currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; + setAutocompleteSelectedIndex(newIndex); + return false; + } + + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return true; + } + + if ( + e.key === "Tab" && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey + ) { + e.preventDefault(); + e.stopPropagation(); + + const autocompleteEnabled = + localStorage.getItem("commandAutocomplete") === "true"; + + if (!autocompleteEnabled) { + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: "\t" }), + ); } - }, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]); + return false; + } - return ( -
-
{ - if (terminal && !splitScreen) { - terminal.focus(); - } - }} - /> + const currentCmd = getCurrentCommandRef.current().trim(); + if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) { + const matches = autocompleteHistory.current + .filter( + (cmd) => + cmd.startsWith(currentCmd) && + cmd !== currentCmd && + cmd.length > currentCmd.length, + ) + .slice(0, 5); - + if (matches.length === 1) { + const completedCommand = matches[0]; + const completion = completedCommand.substring(currentCmd.length); - + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } - + updateCurrentCommandRef.current(completedCommand); + } else if (matches.length > 1) { + currentAutocompleteCommand.current = currentCmd; + setAutocompleteSuggestions(matches); + setAutocompleteSelectedIndex(0); - { - setShowSudoPasswordPopup(false); - if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) { - webSocketRef.current.send(JSON.stringify({ type: "input", data: password + "\n" })); - } - }} - onDismiss={() => setShowSudoPasswordPopup(false)} - /> + const cursorY = terminal.buffer.active.cursorY; + const cursorX = terminal.buffer.active.cursorX; + const rect = xtermRef.current?.getBoundingClientRect(); - -
- ); - }, + if (rect) { + const cellHeight = + terminal.rows > 0 ? rect.height / terminal.rows : 20; + const cellWidth = + terminal.cols > 0 ? rect.width / terminal.cols : 10; + + const itemHeight = 32; + const footerHeight = 32; + const maxMenuHeight = 240; + const estimatedMenuHeight = Math.min( + matches.length * itemHeight + footerHeight, + maxMenuHeight, + ); + const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; + const cursorTopY = rect.top + cursorY * cellHeight; + const spaceBelow = window.innerHeight - cursorBottomY; + const spaceAbove = cursorTopY; + + const showAbove = + spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow; + + setAutocompletePosition({ + top: showAbove + ? Math.max(0, cursorTopY - estimatedMenuHeight) + : cursorBottomY, + left: Math.max(0, rect.left + cursorX * cellWidth), + }); + } + + setShowAutocomplete(true); + } + } + return false; + } + + return true; + }; + + terminal.attachCustomKeyEventHandler(handleCustomKey); + }, [terminal]); + + useEffect(() => { + if (!terminal || !hostConfig || !visible) return; + + if (isConnected || isConnecting) return; + + setIsConnecting(true); + + const readyFonts = + (document as { fonts?: { ready?: Promise } }).fonts + ?.ready instanceof Promise + ? (document as { fonts?: { ready?: Promise } }).fonts.ready + : Promise.resolve(); + + readyFonts.then(() => { + requestAnimationFrame(() => { + fitAddonRef.current?.fit(); + if (terminal && terminal.cols > 0 && terminal.rows > 0) { + scheduleNotify(terminal.cols, terminal.rows); + } + hardRefresh(); + + setVisible(true); + setIsReady(true); + + if (terminal && !splitScreen) { + terminal.focus(); + } + + const jwtToken = getCookie("jwt"); + + if (!jwtToken || jwtToken.trim() === "") { + setIsConnected(false); + setIsConnecting(false); + setConnectionError("Authentication required"); + return; + } + + const cols = terminal.cols; + const rows = terminal.rows; + + connectToHost(cols, rows); + }); + }); + }, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]); + + useEffect(() => { + if (!isVisible || !isReady || !fitAddonRef.current || !terminal) { + return; + } + + let rafId: number; + + rafId = requestAnimationFrame(() => { + performFit(); + }); + + return () => { + if (rafId) cancelAnimationFrame(rafId); + }; + }, [isVisible, isReady, splitScreen, terminal]); + + useEffect(() => { + if ( + isFitted && + isVisible && + isReady && + !isConnecting && + terminal && + !splitScreen + ) { + const rafId = requestAnimationFrame(() => { + terminal.focus(); + }); + return () => cancelAnimationFrame(rafId); + } + }, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]); + + return ( +
+
{ + if (terminal && !splitScreen) { + terminal.focus(); + } + }} + /> + + + + + + + + +
+ ); + }, ); const style = document.createElement("style"); diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index 557c8121..b2bb5287 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -101,7 +101,7 @@ export function UserProfile({ localStorage.getItem("fileColorCoding") !== "false", ); const [commandAutocomplete, setCommandAutocomplete] = useState( - localStorage.getItem("commandAutocomplete") !== "false", + localStorage.getItem("commandAutocomplete") === "true", ); const [defaultSnippetFoldersCollapsed, setDefaultSnippetFoldersCollapsed] = useState( diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 59532a58..218fa6f4 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -927,7 +927,6 @@ export async function createSSHHost(hostData: SSHHostData): Promise { terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), notes: hostData.notes || "", - expirationDate: hostData.expirationDate || "", useSocks5: Boolean(hostData.useSocks5), socks5Host: hostData.socks5Host || null, socks5Port: hostData.socks5Port || null, @@ -1002,7 +1001,6 @@ export async function updateSSHHost( terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), notes: hostData.notes || "", - expirationDate: hostData.expirationDate || "", useSocks5: Boolean(hostData.useSocks5), socks5Host: hostData.socks5Host || null, socks5Port: hostData.socks5Port || null,