From b42ff51a09f149e69b29ee91d025eba7294f3f88 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 20 Nov 2025 23:59:13 -0600 Subject: [PATCH 01/27] feat: Add landscape laayout, file manager, and server stats (squash) --- Terminal.tsx | 1583 ++++++++++++ app.json | 2 +- app/(tabs)/_layout.tsx | 10 + app/Tabs/Hosts/Hosts.tsx | 10 +- app/Tabs/Hosts/Navigation/Host.tsx | 53 +- app/Tabs/Sessions/BottomToolbar.tsx | 136 + app/Tabs/Sessions/CommandHistoryBar.tsx | 346 +++ app/Tabs/Sessions/FileManager.tsx | 564 +++++ app/Tabs/Sessions/FileManager/ContextMenu.tsx | 196 ++ app/Tabs/Sessions/FileManager/FileItem.tsx | 94 + app/Tabs/Sessions/FileManager/FileList.tsx | 88 + .../FileManager/FileManagerHeader.tsx | 142 ++ .../FileManager/FileManagerToolbar.tsx | 118 + app/Tabs/Sessions/FileManager/FileViewer.tsx | 228 ++ .../Sessions/FileManager/utils/fileUtils.ts | 182 ++ app/Tabs/Sessions/KeyboardBar.tsx | 15 +- app/Tabs/Sessions/Navigation/TabBar.tsx | 60 +- app/Tabs/Sessions/ServerStats.tsx | 405 +++ app/Tabs/Sessions/Sessions.tsx | 224 +- app/Tabs/Sessions/SnippetsBar.tsx | 308 +++ app/Tabs/Sessions/Terminal.tsx | 330 ++- app/Tabs/Settings/Settings.tsx | 7 +- app/Tabs/Settings/TerminalCustomization.tsx | 3 +- app/contexts/TerminalSessionsContext.tsx | 32 +- app/main-axios.ts | 2183 +++++++++++++---- app/utils/orientation.ts | 45 + app/utils/responsive.ts | 57 + plugins/withIOSNetworkSecurity.js | 38 + 28 files changed, 6772 insertions(+), 687 deletions(-) create mode 100644 Terminal.tsx create mode 100644 app/Tabs/Sessions/BottomToolbar.tsx create mode 100644 app/Tabs/Sessions/CommandHistoryBar.tsx create mode 100644 app/Tabs/Sessions/FileManager.tsx create mode 100644 app/Tabs/Sessions/FileManager/ContextMenu.tsx create mode 100644 app/Tabs/Sessions/FileManager/FileItem.tsx create mode 100644 app/Tabs/Sessions/FileManager/FileList.tsx create mode 100644 app/Tabs/Sessions/FileManager/FileManagerHeader.tsx create mode 100644 app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx create mode 100644 app/Tabs/Sessions/FileManager/FileViewer.tsx create mode 100644 app/Tabs/Sessions/FileManager/utils/fileUtils.ts create mode 100644 app/Tabs/Sessions/ServerStats.tsx create mode 100644 app/Tabs/Sessions/SnippetsBar.tsx create mode 100644 app/utils/orientation.ts create mode 100644 app/utils/responsive.ts diff --git a/Terminal.tsx b/Terminal.tsx new file mode 100644 index 0000000..e0aec7f --- /dev/null +++ b/Terminal.tsx @@ -0,0 +1,1583 @@ +import { + useEffect, + useRef, + useState, + useImperativeHandle, + forwardRef, + useCallback, +} from "react"; +import { useXTerm } from "react-xtermjs"; +import { FitAddon } from "@xterm/addon-fit"; +import { ClipboardAddon } from "@xterm/addon-clipboard"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + 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, +} from "@/constants/terminal-themes"; +import type { TerminalConfig } from "@/types"; +import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; +import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory"; +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"; + +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; +} + +interface TerminalHandle { + 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; +} + +export const Terminal = forwardRef( + function SSHTerminal( + { + hostConfig, + isVisible, + splitScreen = false, + onClose, + initialPath, + executeCommand, + }, + ref, + ) { + if ( + typeof window !== "undefined" && + !(window as { testJWT?: () => string | null }).testJWT + ) { + (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 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 getCurrentCommandRef = useRef(getCurrentCommand); + const updateCurrentCommandRef = useRef(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 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") !== "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, + ) { + 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); + } 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 }), + ); + } + + setTimeout(() => { + terminal.focus(); + }, 100); + }, + [terminal], + ); + + useEffect(() => { + commandHistoryContext.setOnSelectCommand(handleSelectCommand); + }, [handleSelectCommand]); + + const handleAutocompleteSelect = useCallback( + (selectedCommand: string) => { + if (!webSocketRef.current) return; + + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); + + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } + + 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 = { + ...DEFAULT_TERMINAL_CONFIG, + ...hostConfig.terminalConfig, + }; + + const themeColors = + TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors; + + 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(); + 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); + + const handleMacKeyboard = (e: KeyboardEvent) => { + const isMacOS = + navigator.platform.toUpperCase().indexOf("MAC") >= 0 || + navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; + + 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; + } + + 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 (!isMacOS) return; + + if (e.altKey && !e.metaKey && !e.ctrlKey) { + const keyMappings: { [key: string]: string } = { + "7": "|", + "2": "€", + "8": "[", + "9": "]", + l: "@", + L: "@", + Digit7: "|", + Digit2: "€", + Digit8: "[", + Digit9: "]", + KeyL: "@", + }; + + const char = keyMappings[e.key] || keyMappings[e.code]; + if (char) { + e.preventDefault(); + e.stopPropagation(); + + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }), + ); + } + return false; + } + } + }; + + element?.addEventListener("keydown", handleMacKeyboard, true); + + const resizeObserver = new ResizeObserver(() => { + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + resizeTimeout.current = setTimeout(() => { + if (!isVisibleRef.current || !isReady) return; + performFit(); + }, 50); + }); + + resizeObserver.observe(xtermRef.current); + + setVisible(true); + + 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]); + + useEffect(() => { + if (!terminal) return; + + const handleCustomKey = (e: KeyboardEvent): boolean => { + if (e.type !== "keydown") { + return true; + } + + if (showAutocompleteRef.current) { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return false; + } + + 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 }), + ); + } + } + + updateCurrentCommandRef.current(selectedCommand); + + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + + return false; + } + + 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") !== "false"; + + if (!autocompleteEnabled) { + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: "\t" }), + ); + } + return false; + } + + 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); + + 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"); +style.innerHTML = ` +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap'); + +@font-face { + font-family: 'Caskaydia Cove Nerd Font Mono'; + src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Caskaydia Cove Nerd Font Mono'; + src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype'); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Caskaydia Cove Nerd Font Mono'; + src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype'); + font-weight: normal; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Caskaydia Cove Nerd Font Mono'; + src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype'); + font-weight: bold; + font-style: italic; + font-display: swap; +} + +.xterm .xterm-viewport::-webkit-scrollbar { + width: 8px; + background: transparent; +} +.xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(180,180,180,0.7); + border-radius: 4px; +} +.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: rgba(120,120,120,0.9); +} +.xterm .xterm-viewport { + scrollbar-width: thin; + scrollbar-color: rgba(180,180,180,0.7) transparent; +} + +.xterm { + font-feature-settings: "liga" 1, "calt" 1; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.xterm .xterm-screen { + font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important; + font-variant-ligatures: contextual; +} + +.xterm .xterm-screen .xterm-char { + font-feature-settings: "liga" 1, "calt" 1; +} +`; +document.head.appendChild(style); diff --git a/app.json b/app.json index ab9fe07..0fb3986 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "Termix", "slug": "termix", "version": "1.1.0", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "termix-mobile", "githubUrl": "https://github.com/Termix-SSH/Mobile", diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index de1bcc7..0f607b2 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -2,16 +2,21 @@ import { Tabs, usePathname } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTerminalSessions } from "../contexts/TerminalSessionsContext"; +import { useOrientation } from "../utils/orientation"; +import { getTabBarHeight } from "../utils/responsive"; export default function TabLayout() { const insets = useSafeAreaInsets(); const { sessions } = useTerminalSessions(); const pathname = usePathname(); + const { isLandscape } = useOrientation(); const isSessionsTab = pathname === "/sessions"; const hasActiveSessions = sessions.length > 0; const shouldHideMainTabBar = isSessionsTab && hasActiveSessions; + const tabBarHeight = getTabBarHeight(isLandscape); + return ( ([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -45,6 +48,9 @@ export default function Hosts() { >({}); const isRefreshingRef = useRef(false); + const padding = getResponsivePadding(isLandscape); + const columnCount = getColumnCount(width, isLandscape, 400); + const fetchData = useCallback(async (isRefresh = false) => { if (isRefreshingRef.current) return; @@ -173,8 +179,8 @@ export default function Hosts() { return ( diff --git a/app/Tabs/Hosts/Navigation/Host.tsx b/app/Tabs/Hosts/Navigation/Host.tsx index 8125b26..6ad217a 100644 --- a/app/Tabs/Hosts/Navigation/Host.tsx +++ b/app/Tabs/Hosts/Navigation/Host.tsx @@ -15,6 +15,7 @@ import { Lock, MoreVertical, X, + Activity, } from "lucide-react-native"; import { SSHHost } from "@/types"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; @@ -95,7 +96,17 @@ function Host({ host, status, isLast = false }: HostProps) { }; const handleTerminalPress = () => { - navigateToSessions(host); + navigateToSessions(host, "terminal"); + setShowContextMenu(false); + }; + + const handleStatsPress = () => { + navigateToSessions(host, "stats"); + setShowContextMenu(false); + }; + + const handleFileManagerPress = () => { + navigateToSessions(host, "filemanager"); setShowContextMenu(false); }; @@ -350,6 +361,46 @@ function Host({ host, status, isLast = false }: HostProps) { )} + + + + + View Server Stats + + + Monitor CPU, memory, and disk usage + + + + + {host.enableFileManager && ( + + + + + File Manager + + + Browse and manage files + + + + )} + ; + isVisible: boolean; + keyboardHeight: number; + isKeyboardIntentionallyHidden?: boolean; + currentHostId?: number; +} + +export default function BottomToolbar({ + terminalRef, + isVisible, + keyboardHeight, + isKeyboardIntentionallyHidden = false, + currentHostId, +}: BottomToolbarProps) { + const [mode, setMode] = useState("keyboard"); + const insets = useSafeAreaInsets(); + + if (!isVisible) return null; + + const tabs: { id: ToolbarMode; label: string; icon: string }[] = [ + { id: "keyboard", label: "Keyboard", icon: "⌨️" }, + { id: "snippets", label: "Snippets", icon: "📋" }, + { id: "history", label: "History", icon: "🕒" }, + ]; + + return ( + + {/* Tab Bar */} + + {tabs.map((tab) => ( + setMode(tab.id)} + > + {tab.icon} + + {tab.label} + + + ))} + + + {/* Content Area */} + + {mode === "keyboard" && ( + + )} + + {mode === "snippets" && ( + + )} + + {mode === "history" && ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#0e0e10", + borderTopWidth: 1.5, + borderTopColor: "#303032", + }, + tabBar: { + flexDirection: "row", + backgroundColor: "#18181b", + borderBottomWidth: 1, + borderBottomColor: "#303032", + }, + tab: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 12, + gap: 6, + backgroundColor: "#18181b", + }, + tabActive: { + backgroundColor: "#0e0e10", + borderBottomWidth: 2, + borderBottomColor: "#9333ea", + }, + tabIcon: { + fontSize: 16, + }, + tabLabel: { + fontSize: 13, + fontWeight: "600", + color: "#888", + }, + tabLabelActive: { + color: "#9333ea", + }, + content: { + flex: 1, + }, +}); diff --git a/app/Tabs/Sessions/CommandHistoryBar.tsx b/app/Tabs/Sessions/CommandHistoryBar.tsx new file mode 100644 index 0000000..72e71a2 --- /dev/null +++ b/app/Tabs/Sessions/CommandHistoryBar.tsx @@ -0,0 +1,346 @@ +import React, { useState, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, + TextInput, +} from "react-native"; +import { TerminalHandle } from "./Terminal"; +import { + getCommandHistory, + deleteCommandFromHistory, + clearCommandHistory, +} from "@/app/main-axios"; +import { showToast } from "@/app/utils/toast"; + +interface CommandHistoryItem { + id: number; + command: string; + timestamp: string; + hostId: number; + hostName: string; +} + +interface CommandHistoryBarProps { + terminalRef: React.RefObject; + isVisible: boolean; + height: number; + currentHostId?: number; +} + +export default function CommandHistoryBar({ + terminalRef, + isVisible, + height, + currentHostId, +}: CommandHistoryBarProps) { + const [history, setHistory] = useState([]); + const [filteredHistory, setFilteredHistory] = useState( + [] + ); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (isVisible) { + loadHistory(); + } + }, [isVisible]); + + useEffect(() => { + filterHistory(); + }, [searchQuery, history]); + + const loadHistory = async () => { + try { + setLoading(true); + const historyData = await getCommandHistory(); + + // Sort by timestamp descending (most recent first) + const sortedHistory = historyData.sort( + (a: CommandHistoryItem, b: CommandHistoryItem) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + setHistory(sortedHistory); + } catch (error) { + showToast("Failed to load command history", "error"); + } finally { + setLoading(false); + } + }; + + const filterHistory = () => { + let filtered = history; + + // Filter by current host if specified + if (currentHostId) { + filtered = filtered.filter((item) => item.hostId === currentHostId); + } + + // Filter by search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter((item) => + item.command.toLowerCase().includes(query) + ); + } + + setFilteredHistory(filtered); + }; + + const executeCommand = (command: string) => { + if (terminalRef.current) { + terminalRef.current.sendInput(command + "\n"); + showToast("Command executed", "success"); + } + }; + + const deleteCommand = async (commandId: number) => { + try { + await deleteCommandFromHistory(commandId); + setHistory((prev) => prev.filter((item) => item.id !== commandId)); + showToast("Command deleted", "success"); + } catch (error) { + showToast("Failed to delete command", "error"); + } + }; + + const clearAll = async () => { + try { + await clearCommandHistory(); + setHistory([]); + showToast("History cleared", "success"); + } catch (error) { + showToast("Failed to clear history", "error"); + } + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString(); + }; + + if (!isVisible) return null; + + if (loading) { + return ( + + + + ); + } + + return ( + + + Command History + + + + + {history.length > 0 && ( + + 🗑 + + )} + + + + + + + + + {filteredHistory.map((item) => ( + + executeCommand(item.command)} + > + + {item.command} + + + {item.hostName} + + {formatTimestamp(item.timestamp)} + + + + deleteCommand(item.id)} + > + × + + + ))} + + {filteredHistory.length === 0 && !searchQuery && ( + + No command history yet + + Commands you run will appear here + + + )} + + {filteredHistory.length === 0 && searchQuery && ( + + No matching commands + + Try a different search term + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#0e0e10", + borderTopWidth: 1.5, + borderTopColor: "#303032", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 12, + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: "#303032", + }, + headerText: { + fontSize: 14, + fontWeight: "600", + color: "#e5e5e7", + }, + headerActions: { + flexDirection: "row", + gap: 8, + }, + iconButton: { + padding: 4, + }, + refreshText: { + fontSize: 18, + color: "#9333ea", + }, + clearText: { + fontSize: 16, + color: "#ef4444", + }, + searchContainer: { + paddingHorizontal: 12, + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: "#303032", + }, + searchInput: { + backgroundColor: "#18181b", + color: "#e5e5e7", + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 6, + borderWidth: 1, + borderColor: "#303032", + fontSize: 13, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 8, + paddingVertical: 8, + }, + historyItem: { + flexDirection: "row", + backgroundColor: "#18181b", + borderRadius: 6, + marginBottom: 6, + borderWidth: 1, + borderColor: "#303032", + overflow: "hidden", + }, + commandTouchable: { + flex: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + commandText: { + fontSize: 13, + color: "#e5e5e7", + fontWeight: "500", + fontFamily: "monospace", + marginBottom: 4, + }, + metaRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + hostText: { + fontSize: 11, + color: "#9333ea", + fontWeight: "600", + }, + timestampText: { + fontSize: 11, + color: "#666", + }, + deleteButton: { + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + backgroundColor: "#1a1a1d", + }, + deleteText: { + fontSize: 24, + color: "#ef4444", + fontWeight: "300", + }, + emptyContainer: { + paddingVertical: 32, + alignItems: "center", + }, + emptyText: { + fontSize: 14, + color: "#888", + fontWeight: "600", + }, + emptySubtext: { + fontSize: 12, + color: "#666", + marginTop: 4, + }, +}); diff --git a/app/Tabs/Sessions/FileManager.tsx b/app/Tabs/Sessions/FileManager.tsx new file mode 100644 index 0000000..cf2be13 --- /dev/null +++ b/app/Tabs/Sessions/FileManager.tsx @@ -0,0 +1,564 @@ +import { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from "react"; +import { View, Alert, TextInput, Modal, Text, TouchableOpacity, ActivityIndicator } from "react-native"; +import { SSHHost } from "@/types"; +import { + connectSSH, + listSSHFiles, + readSSHFile, + writeSSHFile, + createSSHFile, + createSSHFolder, + deleteSSHItem, + renameSSHItem, + copySSHItem, + moveSSHItem, + verifySSHTOTP, + keepSSHAlive, +} from "@/app/main-axios"; +import { FileList } from "./FileManager/FileList"; +import { FileManagerHeader } from "./FileManager/FileManagerHeader"; +import { FileManagerToolbar } from "./FileManager/FileManagerToolbar"; +import { ContextMenu } from "./FileManager/ContextMenu"; +import { FileViewer } from "./FileManager/FileViewer"; +import { joinPath, isTextFile, isArchiveFile } from "./FileManager/utils/fileUtils"; +import { showToast } from "@/app/utils/toast"; + +interface FileManagerProps { + host: SSHHost; + sessionId: string; +} + +interface FileItem { + name: string; + path: string; + type: "file" | "directory" | "link"; + size?: number; + modified?: string; + permissions?: string; +} + +export interface FileManagerHandle { + handleDisconnect: () => void; +} + +export const FileManager = forwardRef( + ({ host, sessionId }, ref) => { + const [currentPath, setCurrentPath] = useState("/"); + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const [sshSessionId, setSshSessionId] = useState(null); + + // Selection and clipboard + const [selectionMode, setSelectionMode] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + const [clipboard, setClipboard] = useState<{ + files: string[]; + operation: "copy" | "cut" | null; + }>({ files: [], operation: null }); + + // Dialogs + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + file: FileItem | null; + }>({ visible: false, file: null }); + const [totpDialog, setTotpDialog] = useState(false); + const [totpCode, setTotpCode] = useState(""); + const [createDialog, setCreateDialog] = useState<{ + visible: boolean; + type: "file" | "folder" | null; + }>({ visible: false, type: null }); + const [createName, setCreateName] = useState(""); + const [renameDialog, setRenameDialog] = useState<{ + visible: boolean; + file: FileItem | null; + }>({ visible: false, file: null }); + const [renameName, setRenameName] = useState(""); + const [fileViewer, setFileViewer] = useState<{ + visible: boolean; + file: FileItem | null; + content: string; + }>({ visible: false, file: null, content: "" }); + + // Keepalive + const keepaliveInterval = useRef(null); + + // Connect to SSH + const connectToSSH = useCallback(async () => { + try { + setIsLoading(true); + const response = await connectSSH(sessionId, { + hostId: host.id, + ip: host.ip, + port: host.port, + username: host.username, + password: host.authType === "password" ? host.password : undefined, + sshKey: host.authType === "key" ? host.key : undefined, + keyPassword: host.keyPassword, + authType: host.authType, + credentialId: host.credentialId, + forceKeyboardInteractive: host.forceKeyboardInteractive, + }); + + if (response.requires_totp) { + setTotpDialog(true); + return; + } + + setSshSessionId(sessionId); + setIsConnected(true); + + // Start keepalive + keepaliveInterval.current = setInterval(() => { + keepSSHAlive(sessionId).catch(() => {}); + }, 30000); + + // Load initial directory + await loadDirectory(host.defaultPath || "/"); + } catch (error: any) { + showToast(error.message || "Failed to connect to SSH", "error"); + } finally { + setIsLoading(false); + } + }, [host, sessionId]); + + const handleTOTPVerify = async () => { + try { + await verifySSHTOTP(sessionId, totpCode); + setTotpDialog(false); + setTotpCode(""); + setSshSessionId(sessionId); + setIsConnected(true); + + // Start keepalive + keepaliveInterval.current = setInterval(() => { + keepSSHAlive(sessionId).catch(() => {}); + }, 30000); + + // Load initial directory + await loadDirectory(host.defaultPath || "/"); + } catch (error: any) { + showToast(error.message || "Invalid TOTP code", "error"); + } + }; + + const loadDirectory = useCallback(async (path: string) => { + if (!sessionId) return; + + try { + setIsLoading(true); + const response = await listSSHFiles(sessionId, path); + setFiles(response.files || []); + setCurrentPath(response.path || path); + } catch (error: any) { + showToast(error.message || "Failed to load directory", "error"); + } finally { + setIsLoading(false); + } + }, [sessionId]); + + // File operations + const handleFilePress = (file: FileItem) => { + if (file.type === "directory") { + loadDirectory(file.path); + } else if (isTextFile(file.name)) { + handleViewFile(file); + } else { + showToast("File type not supported for viewing", "info"); + } + }; + + const handleFileLongPress = (file: FileItem) => { + setContextMenu({ visible: true, file }); + }; + + const handleViewFile = async (file: FileItem) => { + try { + setIsLoading(true); + const response = await readSSHFile(sessionId!, file.path); + setFileViewer({ visible: true, file, content: response.content }); + } catch (error: any) { + showToast(error.message || "Failed to read file", "error"); + } finally { + setIsLoading(false); + } + }; + + const handleSaveFile = async (content: string) => { + if (!fileViewer.file) return; + + try { + await writeSSHFile(sessionId!, fileViewer.file.path, content, host.id); + showToast("File saved successfully", "success"); + await loadDirectory(currentPath); + } catch (error: any) { + throw new Error(error.message || "Failed to save file"); + } + }; + + const handleCreateFolder = () => { + setCreateDialog({ visible: true, type: "folder" }); + setCreateName(""); + }; + + const handleCreateFile = () => { + setCreateDialog({ visible: true, type: "file" }); + setCreateName(""); + }; + + const handleCreateConfirm = async () => { + if (!createDialog.type || !createName.trim()) return; + + try { + setIsLoading(true); + if (createDialog.type === "folder") { + await createSSHFolder(sessionId!, currentPath, createName, host.id); + showToast("Folder created successfully", "success"); + } else { + await createSSHFile(sessionId!, currentPath, createName, "", host.id); + showToast("File created successfully", "success"); + } + setCreateDialog({ visible: false, type: null }); + setCreateName(""); + await loadDirectory(currentPath); + } catch (error: any) { + showToast(error.message || "Failed to create item", "error"); + } finally { + setIsLoading(false); + } + }; + + const handleRename = (file: FileItem) => { + setRenameDialog({ visible: true, file }); + setRenameName(file.name); + }; + + const handleRenameConfirm = async () => { + if (!renameDialog.file || !renameName.trim()) return; + + try { + setIsLoading(true); + await renameSSHItem(sessionId!, renameDialog.file.path, renameName, host.id); + showToast("Item renamed successfully", "success"); + setRenameDialog({ visible: false, file: null }); + setRenameName(""); + await loadDirectory(currentPath); + } catch (error: any) { + showToast(error.message || "Failed to rename item", "error"); + } finally { + setIsLoading(false); + } + }; + + const handleCopy = (file?: FileItem) => { + const filesToCopy = file ? [file.path] : selectedFiles; + setClipboard({ files: filesToCopy, operation: "copy" }); + setSelectionMode(false); + setSelectedFiles([]); + showToast(`${filesToCopy.length} item(s) copied`, "success"); + }; + + const handleCut = (file?: FileItem) => { + const filesToCut = file ? [file.path] : selectedFiles; + setClipboard({ files: filesToCut, operation: "cut" }); + setSelectionMode(false); + setSelectedFiles([]); + showToast(`${filesToCut.length} item(s) cut`, "success"); + }; + + const handlePaste = async () => { + if (clipboard.files.length === 0 || !clipboard.operation) return; + + try { + setIsLoading(true); + for (const filePath of clipboard.files) { + if (clipboard.operation === "copy") { + await copySSHItem(sessionId!, filePath, currentPath, host.id); + } else { + await moveSSHItem(sessionId!, filePath, joinPath(currentPath, filePath.split("/").pop()!), host.id); + } + } + showToast(`${clipboard.files.length} item(s) pasted`, "success"); + setClipboard({ files: [], operation: null }); + await loadDirectory(currentPath); + } catch (error: any) { + showToast(error.message || "Failed to paste items", "error"); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async (file?: FileItem) => { + const filesToDelete = file ? [file] : files.filter((f) => selectedFiles.includes(f.path)); + + Alert.alert( + "Confirm Delete", + `Are you sure you want to delete ${filesToDelete.length} item(s)?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + setIsLoading(true); + for (const fileItem of filesToDelete) { + await deleteSSHItem( + sessionId!, + fileItem.path, + fileItem.type === "directory", + host.id + ); + } + showToast(`${filesToDelete.length} item(s) deleted`, "success"); + setSelectionMode(false); + setSelectedFiles([]); + await loadDirectory(currentPath); + } catch (error: any) { + showToast(error.message || "Failed to delete items", "error"); + } finally { + setIsLoading(false); + } + }, + }, + ] + ); + }; + + const handleSelectToggle = (path: string) => { + setSelectedFiles((prev) => + prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path] + ); + }; + + const handleCancelSelection = () => { + setSelectionMode(false); + setSelectedFiles([]); + }; + + // Initialize + useEffect(() => { + connectToSSH(); + + return () => { + if (keepaliveInterval.current) { + clearInterval(keepaliveInterval.current); + } + }; + }, [connectToSSH]); + + // Expose disconnect method to parent + useImperativeHandle(ref, () => ({ + handleDisconnect: () => { + if (keepaliveInterval.current) { + clearInterval(keepaliveInterval.current); + } + setIsConnected(false); + }, + })); + + if (!isConnected) { + return ( + + + Connecting to {host.name}... + + {/* TOTP Dialog */} + + + + + Two-Factor Authentication + + + Enter your TOTP code to continue + + + + { + setTotpDialog(false); + setTotpCode(""); + }} + className="flex-1 bg-dark-bg-darker border border-dark-border rounded py-3" + activeOpacity={0.7} + > + Cancel + + + Verify + + + + + + + ); + } + + return ( + + loadDirectory(currentPath)} + onCreateFolder={handleCreateFolder} + onCreateFile={handleCreateFile} + onMenuPress={() => setSelectionMode(true)} + isLoading={isLoading} + /> + + loadDirectory(currentPath)} + /> + + handleCopy()} + onCut={() => handleCut()} + onPaste={handlePaste} + onDelete={() => handleDelete()} + onCancelSelection={handleCancelSelection} + clipboardCount={clipboard.files.length} + clipboardOperation={clipboard.operation} + /> + + {/* Context Menu */} + {contextMenu.file && ( + setContextMenu({ visible: false, file: null })} + fileName={contextMenu.file.name} + fileType={contextMenu.file.type} + onView={ + isTextFile(contextMenu.file.name) + ? () => handleViewFile(contextMenu.file!) + : undefined + } + onEdit={ + isTextFile(contextMenu.file.name) + ? () => handleViewFile(contextMenu.file!) + : undefined + } + onRename={() => handleRename(contextMenu.file!)} + onCopy={() => handleCopy(contextMenu.file!)} + onCut={() => handleCut(contextMenu.file!)} + onDelete={() => handleDelete(contextMenu.file!)} + isArchive={isArchiveFile(contextMenu.file.name)} + /> + )} + + {/* Create Dialog */} + + + + + Create New {createDialog.type === "folder" ? "Folder" : "File"} + + + + { + setCreateDialog({ visible: false, type: null }); + setCreateName(""); + }} + className="flex-1 bg-dark-bg-darker border border-dark-border rounded py-3" + activeOpacity={0.7} + > + Cancel + + + Create + + + + + + + {/* Rename Dialog */} + + + + + Rename Item + + + + { + setRenameDialog({ visible: false, file: null }); + setRenameName(""); + }} + className="flex-1 bg-dark-bg-darker border border-dark-border rounded py-3" + activeOpacity={0.7} + > + Cancel + + + Rename + + + + + + + {/* File Viewer */} + {fileViewer.file && ( + setFileViewer({ visible: false, file: null, content: "" })} + fileName={fileViewer.file.name} + filePath={fileViewer.file.path} + initialContent={fileViewer.content} + onSave={handleSaveFile} + /> + )} + + ); + } +); + +FileManager.displayName = "FileManager"; diff --git a/app/Tabs/Sessions/FileManager/ContextMenu.tsx b/app/Tabs/Sessions/FileManager/ContextMenu.tsx new file mode 100644 index 0000000..e78f8e1 --- /dev/null +++ b/app/Tabs/Sessions/FileManager/ContextMenu.tsx @@ -0,0 +1,196 @@ +import { + Modal, + View, + Text, + TouchableOpacity, + TouchableWithoutFeedback, +} from "react-native"; +import { + Eye, + Edit, + Copy, + Scissors, + Trash2, + FileText, + Download, + Lock, + Archive, + PackageOpen, + X, +} from "lucide-react-native"; + +interface ContextMenuProps { + visible: boolean; + onClose: () => void; + fileName: string; + fileType: "file" | "directory" | "link"; + onView?: () => void; + onEdit?: () => void; + onRename: () => void; + onCopy: () => void; + onCut: () => void; + onDelete: () => void; + onDownload?: () => void; + onPermissions?: () => void; + onCompress?: () => void; + onExtract?: () => void; + isArchive?: boolean; +} + +export function ContextMenu({ + visible, + onClose, + fileName, + fileType, + onView, + onEdit, + onRename, + onCopy, + onCut, + onDelete, + onDownload, + onPermissions, + onCompress, + onExtract, + isArchive = false, +}: ContextMenuProps) { + const handleAction = (action: () => void) => { + action(); + onClose(); + }; + + return ( + + + + {}}> + + {/* Header */} + + + {fileName} + + + + + + + {/* Actions */} + + {onView && fileType === "file" && ( + handleAction(onView)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + View + + )} + + {onEdit && fileType === "file" && ( + handleAction(onEdit)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Edit + + )} + + handleAction(onRename)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Rename + + + handleAction(onCopy)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Copy + + + handleAction(onCut)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Cut + + + {onDownload && fileType === "file" && ( + handleAction(onDownload)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Download + + )} + + {onPermissions && ( + handleAction(onPermissions)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Permissions + + )} + + {onCompress && ( + handleAction(onCompress)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Compress + + )} + + {onExtract && isArchive && ( + handleAction(onExtract)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Extract + + )} + + handleAction(onDelete)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-red-500" + activeOpacity={0.7} + > + + Delete + + + + + + + + ); +} diff --git a/app/Tabs/Sessions/FileManager/FileItem.tsx b/app/Tabs/Sessions/FileManager/FileItem.tsx new file mode 100644 index 0000000..ea30850 --- /dev/null +++ b/app/Tabs/Sessions/FileManager/FileItem.tsx @@ -0,0 +1,94 @@ +import { View, Text, TouchableOpacity } from "react-native"; +import { File, Folder, Link } from "lucide-react-native"; +import { formatFileSize, formatDate, getFileIconColor } from "./utils/fileUtils"; + +interface FileItemProps { + name: string; + type: "file" | "directory" | "link"; + size?: number; + modified?: string; + isSelected?: boolean; + onPress: () => void; + onLongPress: () => void; + onSelectToggle?: () => void; + selectionMode?: boolean; +} + +export function FileItem({ + name, + type, + size, + modified, + isSelected = false, + onPress, + onLongPress, + onSelectToggle, + selectionMode = false, +}: FileItemProps) { + const iconColor = getFileIconColor(name, type); + const IconComponent = type === "directory" ? Folder : type === "link" ? Link : File; + + return ( + + {/* Selection Checkbox (visible in selection mode) */} + {selectionMode && ( + + + {isSelected && } + + + )} + + {/* File Icon */} + + + + + {/* File Info */} + + + {name} + + + {type === "directory" ? ( + Folder + ) : ( + <> + {size !== undefined && ( + {formatFileSize(size)} + )} + {modified && ( + <> + {size !== undefined && ( + + )} + {formatDate(modified)} + + )} + + )} + + + + {/* Link indicator */} + {type === "link" && !selectionMode && ( + + + + )} + + ); +} diff --git a/app/Tabs/Sessions/FileManager/FileList.tsx b/app/Tabs/Sessions/FileManager/FileList.tsx new file mode 100644 index 0000000..4154428 --- /dev/null +++ b/app/Tabs/Sessions/FileManager/FileList.tsx @@ -0,0 +1,88 @@ +import { ScrollView, RefreshControl, View, Text } from "react-native"; +import { FileItem } from "./FileItem"; +import { sortFiles } from "./utils/fileUtils"; + +interface FileListItem { + name: string; + path: string; + type: "file" | "directory" | "link"; + size?: number; + modified?: string; + permissions?: string; +} + +interface FileListProps { + files: FileListItem[]; + onFilePress: (file: FileListItem) => void; + onFileLongPress: (file: FileListItem) => void; + selectedFiles: string[]; + onSelectToggle: (path: string) => void; + selectionMode: boolean; + isLoading: boolean; + onRefresh: () => void; + sortBy?: "name" | "size" | "modified"; + sortOrder?: "asc" | "desc"; +} + +export function FileList({ + files, + onFilePress, + onFileLongPress, + selectedFiles, + onSelectToggle, + selectionMode, + isLoading, + onRefresh, + sortBy = "name", + sortOrder = "asc", +}: FileListProps) { + const sortedFiles = sortFiles(files, sortBy, sortOrder); + + if (!isLoading && files.length === 0) { + return ( + + } + > + This folder is empty + + ); + } + + return ( + + } + > + {sortedFiles.map((file) => ( + onFilePress(file)} + onLongPress={() => onFileLongPress(file)} + onSelectToggle={() => onSelectToggle(file.path)} + selectionMode={selectionMode} + /> + ))} + {/* Add bottom padding for toolbar */} + + + ); +} diff --git a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx new file mode 100644 index 0000000..2085c9f --- /dev/null +++ b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx @@ -0,0 +1,142 @@ +import { View, Text, TouchableOpacity, ScrollView } from "react-native"; +import { + ChevronLeft, + RefreshCw, + FolderPlus, + FilePlus, + Upload, + MoreVertical, +} from "lucide-react-native"; +import { breadcrumbsFromPath, getBreadcrumbLabel } from "./utils/fileUtils"; + +interface FileManagerHeaderProps { + currentPath: string; + onNavigateToPath: (path: string) => void; + onRefresh: () => void; + onCreateFolder: () => void; + onCreateFile: () => void; + onUpload?: () => void; + onMenuPress: () => void; + isLoading: boolean; +} + +export function FileManagerHeader({ + currentPath, + onNavigateToPath, + onRefresh, + onCreateFolder, + onCreateFile, + onUpload, + onMenuPress, + isLoading, +}: FileManagerHeaderProps) { + const breadcrumbs = breadcrumbsFromPath(currentPath); + const isRoot = currentPath === "/"; + + return ( + + {/* Path breadcrumbs */} + + + {!isRoot && ( + { + const parentPath = breadcrumbs[breadcrumbs.length - 2] || "/"; + onNavigateToPath(parentPath); + }} + className="mr-2 p-1" + activeOpacity={0.7} + > + + + )} + + {breadcrumbs.map((path, index) => ( + + {index > 0 && ( + / + )} + onNavigateToPath(path)} + className={`px-2 py-1 rounded ${ + index === breadcrumbs.length - 1 + ? "bg-dark-bg-button" + : "" + }`} + activeOpacity={0.7} + > + + {getBreadcrumbLabel(path)} + + + + ))} + + + + {/* Action buttons */} + + + + + + + + + + + + + + {onUpload && ( + + + + )} + + + + + + + + + ); +} diff --git a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx new file mode 100644 index 0000000..20b9fba --- /dev/null +++ b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx @@ -0,0 +1,118 @@ +import { View, Text, TouchableOpacity } from "react-native"; +import { Copy, Scissors, Clipboard, Trash2, X } from "lucide-react-native"; + +interface FileManagerToolbarProps { + selectionMode: boolean; + selectedCount: number; + onCopy: () => void; + onCut: () => void; + onPaste: () => void; + onDelete: () => void; + onCancelSelection: () => void; + clipboardCount?: number; + clipboardOperation?: "copy" | "cut" | null; +} + +export function FileManagerToolbar({ + selectionMode, + selectedCount, + onCopy, + onCut, + onPaste, + onDelete, + onCancelSelection, + clipboardCount = 0, + clipboardOperation = null, +}: FileManagerToolbarProps) { + if (!selectionMode && clipboardCount === 0) { + return null; + } + + return ( + + {selectionMode ? ( + + {/* Selection count */} + + {selectedCount} selected + + + + {/* Copy */} + + + + + {/* Cut */} + + + + + {/* Delete */} + + + + + {/* Cancel */} + + + + + + ) : ( + + {/* Clipboard info */} + + {clipboardOperation === "copy" ? ( + + ) : ( + + )} + + {clipboardCount} item{clipboardCount !== 1 ? "s" : ""}{" "} + {clipboardOperation === "copy" ? "copied" : "cut"} + + + + {/* Paste button */} + + + Paste + + + )} + + ); +} diff --git a/app/Tabs/Sessions/FileManager/FileViewer.tsx b/app/Tabs/Sessions/FileManager/FileViewer.tsx new file mode 100644 index 0000000..dda42f8 --- /dev/null +++ b/app/Tabs/Sessions/FileManager/FileViewer.tsx @@ -0,0 +1,228 @@ +import { useState, useEffect } from "react"; +import { + Modal, + View, + Text, + TouchableOpacity, + TextInput, + ScrollView, + ActivityIndicator, + Alert, +} from "react-native"; +import { X, Save, RotateCcw } from "lucide-react-native"; + +interface FileViewerProps { + visible: boolean; + onClose: () => void; + fileName: string; + filePath: string; + initialContent: string; + onSave: (content: string) => Promise; + readOnly?: boolean; +} + +export function FileViewer({ + visible, + onClose, + fileName, + filePath, + initialContent, + onSave, + readOnly = false, +}: FileViewerProps) { + const [content, setContent] = useState(initialContent); + const [isSaving, setIsSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + setContent(initialContent); + setHasChanges(false); + }, [initialContent, visible]); + + const handleContentChange = (newContent: string) => { + setContent(newContent); + setHasChanges(newContent !== initialContent); + }; + + const handleSave = async () => { + if (!hasChanges || readOnly) return; + + try { + setIsSaving(true); + await onSave(content); + setHasChanges(false); + Alert.alert("Success", "File saved successfully"); + } catch (error: any) { + Alert.alert("Error", error.message || "Failed to save file"); + } finally { + setIsSaving(false); + } + }; + + const handleRevert = () => { + if (!hasChanges) return; + + Alert.alert( + "Revert Changes", + "Are you sure you want to discard your changes?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Revert", + style: "destructive", + onPress: () => { + setContent(initialContent); + setHasChanges(false); + }, + }, + ] + ); + }; + + const handleClose = () => { + if (hasChanges && !readOnly) { + Alert.alert( + "Unsaved Changes", + "You have unsaved changes. Do you want to save before closing?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: onClose, + }, + { + text: "Save", + onPress: async () => { + await handleSave(); + onClose(); + }, + }, + ] + ); + } else { + onClose(); + } + }; + + return ( + + + {/* Header */} + + + + + {fileName} + + + {filePath} + + + + + {!readOnly && hasChanges && ( + <> + + + + + + {isSaving ? ( + + ) : ( + + )} + + + )} + + + + + + + + {hasChanges && !readOnly && ( + + Unsaved changes + + )} + + {readOnly && ( + + Read-only mode + + )} + + + {/* Content */} + + + + + {/* Bottom buttons (mobile-friendly) */} + {!readOnly && hasChanges && ( + + + + + Revert + + + + {isSaving ? ( + + ) : ( + <> + + Save + + )} + + + + )} + + + ); +} diff --git a/app/Tabs/Sessions/FileManager/utils/fileUtils.ts b/app/Tabs/Sessions/FileManager/utils/fileUtils.ts new file mode 100644 index 0000000..4f52b7e --- /dev/null +++ b/app/Tabs/Sessions/FileManager/utils/fileUtils.ts @@ -0,0 +1,182 @@ +// File utility functions for the file manager + +export function formatFileSize(bytes: number | undefined): string { + if (!bytes || bytes === 0) return "0 B"; + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +export function getFileExtension(filename: string): string { + const parts = filename.split("."); + if (parts.length === 1) return ""; + return parts[parts.length - 1].toLowerCase(); +} + +export function getFileName(path: string): string { + const parts = path.split("/"); + return parts[parts.length - 1] || path; +} + +export function getParentPath(path: string): string { + if (path === "/" || !path) return "/"; + const parts = path.split("/").filter((p) => p); + parts.pop(); + return "/" + parts.join("/"); +} + +export function joinPath(...parts: string[]): string { + const joined = parts + .map((part) => part.replace(/^\/+|\/+$/g, "")) + .filter((part) => part) + .join("/"); + return "/" + joined; +} + +export function isTextFile(filename: string): boolean { + const ext = getFileExtension(filename); + const textExtensions = [ + "txt", "md", "json", "xml", "html", "css", "js", "ts", "tsx", "jsx", + "py", "java", "c", "cpp", "h", "hpp", "cs", "php", "rb", "go", "rs", + "sh", "bash", "zsh", "fish", "yml", "yaml", "toml", "ini", "cfg", "conf", + "log", "env", "gitignore", "dockerignore", "editorconfig", "prettierrc", + ]; + return textExtensions.includes(ext); +} + +export function isArchiveFile(filename: string): boolean { + const ext = getFileExtension(filename); + const archiveExtensions = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"]; + return archiveExtensions.includes(ext); +} + +export function isImageFile(filename: string): boolean { + const ext = getFileExtension(filename); + const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "svg", "webp", "ico"]; + return imageExtensions.includes(ext); +} + +export function isVideoFile(filename: string): boolean { + const ext = getFileExtension(filename); + const videoExtensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm"]; + return videoExtensions.includes(ext); +} + +export function formatDate(dateString: string | undefined): string { + if (!dateString) return ""; + + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); + } catch { + return ""; + } +} + +export function sortFiles( + files: any[], + sortBy: "name" | "size" | "modified" = "name", + sortOrder: "asc" | "desc" = "asc" +): any[] { + const sorted = [...files].sort((a, b) => { + // Always put directories first + if (a.type === "directory" && b.type !== "directory") return -1; + if (a.type !== "directory" && b.type === "directory") return 1; + + let compareValue = 0; + + switch (sortBy) { + case "name": + compareValue = a.name.localeCompare(b.name); + break; + case "size": + compareValue = (a.size || 0) - (b.size || 0); + break; + case "modified": + compareValue = new Date(a.modified || 0).getTime() - new Date(b.modified || 0).getTime(); + break; + } + + return sortOrder === "asc" ? compareValue : -compareValue; + }); + + return sorted; +} + +export function getFileIconColor(filename: string, type: string): string { + if (type === "directory") return "#3B82F6"; // blue + if (type === "link") return "#8B5CF6"; // purple + + const ext = getFileExtension(filename); + + // Code files + if (["js", "jsx", "ts", "tsx"].includes(ext)) return "#F59E0B"; // amber + if (["py"].includes(ext)) return "#3B82F6"; // blue + if (["java", "class"].includes(ext)) return "#EF4444"; // red + if (["c", "cpp", "h", "hpp"].includes(ext)) return "#06B6D4"; // cyan + if (["go"].includes(ext)) return "#06B6D4"; // cyan + if (["rs"].includes(ext)) return "#F97316"; // orange + + // Web files + if (["html", "htm"].includes(ext)) return "#F97316"; // orange + if (["css", "scss", "sass", "less"].includes(ext)) return "#3B82F6"; // blue + if (["json", "xml"].includes(ext)) return "#F59E0B"; // amber + + // Config files + if (["yml", "yaml", "toml", "ini", "conf", "cfg"].includes(ext)) return "#8B5CF6"; // purple + if (["env", "gitignore", "dockerignore"].includes(ext)) return "#6B7280"; // gray + + // Documents + if (["md", "txt"].includes(ext)) return "#10B981"; // green + if (["pdf"].includes(ext)) return "#EF4444"; // red + if (["doc", "docx"].includes(ext)) return "#3B82F6"; // blue + + // Archives + if (isArchiveFile(filename)) return "#8B5CF6"; // purple + + // Images + if (isImageFile(filename)) return "#EC4899"; // pink + + // Videos + if (isVideoFile(filename)) return "#F59E0B"; // amber + + // Shell scripts + if (["sh", "bash", "zsh", "fish"].includes(ext)) return "#10B981"; // green + + // Default + return "#9CA3AF"; // gray-400 +} + +export function breadcrumbsFromPath(path: string): string[] { + if (!path || path === "/") return ["/"]; + const parts = path.split("/").filter((p) => p); + const breadcrumbs = ["/"]; + parts.forEach((part, index) => { + breadcrumbs.push("/" + parts.slice(0, index + 1).join("/")); + }); + return breadcrumbs; +} + +export function getBreadcrumbLabel(path: string): string { + if (path === "/") return "/"; + const parts = path.split("/").filter((p) => p); + return parts[parts.length - 1] || "/"; +} diff --git a/app/Tabs/Sessions/KeyboardBar.tsx b/app/Tabs/Sessions/KeyboardBar.tsx index 092c65a..1f02240 100644 --- a/app/Tabs/Sessions/KeyboardBar.tsx +++ b/app/Tabs/Sessions/KeyboardBar.tsx @@ -12,6 +12,7 @@ import KeyboardKey from "./KeyboardKey"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; import { KeyConfig } from "@/types/keyboard"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; +import { useOrientation } from "@/app/utils/orientation"; interface KeyboardBarProps { terminalRef: React.RefObject; @@ -28,6 +29,7 @@ export default function KeyboardBar({ }: KeyboardBarProps) { const { config } = useKeyboardCustomization(); const { keyboardHeight, isKeyboardVisible } = useKeyboard(); + const { isLandscape } = useOrientation(); const [ctrlPressed, setCtrlPressed] = useState(false); const [altPressed, setAltPressed] = useState(false); @@ -129,12 +131,16 @@ export default function KeyboardBar({ style={[ styles.keyboardBar, isKeyboardIntentionallyHidden && { paddingBottom: 16 }, + isLandscape && styles.keyboardBarLandscape, ]} > {hasPinnedKeys && ( @@ -156,12 +162,19 @@ const styles = StyleSheet.create({ borderTopWidth: 1.5, borderTopColor: "#303032", }, + keyboardBarLandscape: { + borderTopWidth: 1, + }, scrollContent: { paddingHorizontal: 8, paddingVertical: 8, alignItems: "center", gap: 6, }, + scrollContentLandscape: { + paddingVertical: 6, + gap: 4, + }, separator: { width: 1, height: 30, diff --git a/app/Tabs/Sessions/Navigation/TabBar.tsx b/app/Tabs/Sessions/Navigation/TabBar.tsx index 1e4fa2c..3035844 100644 --- a/app/Tabs/Sessions/Navigation/TabBar.tsx +++ b/app/Tabs/Sessions/Navigation/TabBar.tsx @@ -18,6 +18,8 @@ import { import { TerminalSession } from "@/app/contexts/TerminalSessionsContext"; import { useRouter } from "expo-router"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; +import { useOrientation } from "@/app/utils/orientation"; +import { getTabBarHeight, getButtonSize } from "@/app/utils/responsive"; interface TabBarProps { sessions: TerminalSession[]; @@ -48,6 +50,10 @@ export default function TabBar({ }: TabBarProps) { const router = useRouter(); const { isKeyboardVisible } = useKeyboard(); + const { isLandscape } = useOrientation(); + + const tabBarHeight = getTabBarHeight(isLandscape); + const buttonSize = getButtonSize(isLandscape); const handleToggleSystemKeyboard = () => { if (keyboardIntentionallyHiddenRef.current) { @@ -69,10 +75,10 @@ export default function TabBar({ @@ -90,9 +96,9 @@ export default function TabBar({ className="items-center justify-center rounded-md" activeOpacity={0.7} style={{ - width: 44, - height: 44, - borderWidth: 2, + width: buttonSize, + height: buttonSize, + borderWidth: isLandscape ? 1.5 : 2, borderColor: "#303032", backgroundColor: "#2a2a2a", shadowColor: "#000", @@ -100,10 +106,10 @@ export default function TabBar({ shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, - marginRight: 8, + marginRight: isLandscape ? 6 : 8, }} > - + @@ -142,7 +148,7 @@ export default function TabBar({ focusable={false} className="flex-row items-center rounded-md" style={{ - borderWidth: 2, + borderWidth: isLandscape ? 1.5 : 2, borderColor: isActive ? "#22c55e" : "#303032", backgroundColor: isActive ? "#1a1a1a" : "#1a1a1a", shadowColor: isActive ? "#22c55e" : "transparent", @@ -150,8 +156,8 @@ export default function TabBar({ shadowOpacity: isActive ? 0.2 : 0, shadowRadius: 4, elevation: isActive ? 3 : 0, - minWidth: 120, - height: 44, + minWidth: isLandscape ? 100 : 120, + height: buttonSize, }} > @@ -173,14 +179,14 @@ export default function TabBar({ className="items-center justify-center" activeOpacity={0.7} style={{ - width: 36, - height: 44, - borderLeftWidth: 2, + width: isLandscape ? 32 : 36, + height: buttonSize, + borderLeftWidth: isLandscape ? 1.5 : 2, borderLeftColor: isActive ? "#22c55e" : "#303032", }} > @@ -198,9 +204,9 @@ export default function TabBar({ className="items-center justify-center rounded-md" activeOpacity={0.7} style={{ - width: 44, - height: 44, - borderWidth: 2, + width: buttonSize, + height: buttonSize, + borderWidth: isLandscape ? 1.5 : 2, borderColor: "#303032", backgroundColor: "#2a2a2a", shadowColor: "#000", @@ -208,13 +214,13 @@ export default function TabBar({ shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, - marginLeft: 8, + marginLeft: isLandscape ? 6 : 8, }} > {keyboardIntentionallyHiddenRef.current ? ( - + ) : ( - + )} )} @@ -225,9 +231,9 @@ export default function TabBar({ className="items-center justify-center rounded-md" activeOpacity={0.7} style={{ - width: 44, - height: 44, - borderWidth: 2, + width: buttonSize, + height: buttonSize, + borderWidth: isLandscape ? 1.5 : 2, borderColor: "#303032", backgroundColor: "#2a2a2a", shadowColor: "#000", @@ -235,13 +241,13 @@ export default function TabBar({ shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, - marginLeft: 8, + marginLeft: isLandscape ? 6 : 8, }} > {isCustomKeyboardVisible ? ( - + ) : ( - + )} diff --git a/app/Tabs/Sessions/ServerStats.tsx b/app/Tabs/Sessions/ServerStats.tsx new file mode 100644 index 0000000..25f4885 --- /dev/null +++ b/app/Tabs/Sessions/ServerStats.tsx @@ -0,0 +1,405 @@ +import React, { + useRef, + useEffect, + useState, + useCallback, + forwardRef, + useImperativeHandle, +} from "react"; +import { + View, + Text, + ScrollView, + ActivityIndicator, + RefreshControl, + TouchableOpacity, +} from "react-native"; +import { + Cpu, + MemoryStick, + HardDrive, + Activity, + Clock, + Server, +} from "lucide-react-native"; +import { getServerMetricsById } from "../../main-axios"; +import { showToast } from "../../utils/toast"; +import type { ServerMetrics } from "../../../types/index"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding, getColumnCount } from "@/app/utils/responsive"; + +interface ServerStatsProps { + hostConfig: { + id: number; + name: string; + }; + isVisible: boolean; + title?: string; + onClose?: () => void; +} + +export type ServerStatsHandle = { + refresh: () => void; +}; + +export const ServerStats = forwardRef( + ({ hostConfig, isVisible, title = "Server Stats", onClose }, ref) => { + const { width, isLandscape } = useOrientation(); + const [metrics, setMetrics] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const refreshIntervalRef = useRef(null); + + const padding = getResponsivePadding(isLandscape); + const columnCount = getColumnCount(width, isLandscape, 350); + + const fetchMetrics = useCallback( + async (showLoadingSpinner = true) => { + try { + if (showLoadingSpinner) { + setIsLoading(true); + } + setError(null); + + const data = await getServerMetricsById(hostConfig.id); + setMetrics(data); + } catch (err: any) { + const errorMessage = err?.message || "Failed to fetch server metrics"; + setError(errorMessage); + if (showLoadingSpinner) { + showToast.error(errorMessage); + } + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, + [hostConfig.id], + ); + + const handleRefresh = useCallback(() => { + setIsRefreshing(true); + fetchMetrics(false); + }, [fetchMetrics]); + + useImperativeHandle( + ref, + () => ({ + refresh: handleRefresh, + }), + [handleRefresh], + ); + + useEffect(() => { + if (isVisible) { + fetchMetrics(); + + // Auto-refresh every 5 seconds + refreshIntervalRef.current = setInterval(() => { + fetchMetrics(false); + }, 5000); + } else { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + } + + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + } + }; + }, [isVisible, fetchMetrics]); + + const formatUptime = (seconds: number | null): string => { + if (seconds === null || seconds === undefined) return "N/A"; + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } + }; + + const renderMetricCard = ( + icon: React.ReactNode, + title: string, + value: string, + subtitle: string, + color: string, + ) => { + const cardWidth = isLandscape && columnCount > 1 + ? `${100 / columnCount - 1}%` + : "100%"; + + return ( + 1 ? 0 : 12, + width: cardWidth, + }} + > + + + {icon} + + {title} + + + + + + {value} + + + {subtitle} + + + + ); + }; + + if (!isVisible) { + return null; + } + + return ( + + {isLoading && !metrics ? ( + + + + Loading server metrics... + + + ) : error ? ( + + + + Failed to Load Metrics + + + {error} + + + + Retry + + + + ) : ( + 1 ? "row" : "column", + flexWrap: "wrap", + gap: 12, + }} + refreshControl={ + + } + > + {/* Header */} + + + {hostConfig.name} + + + Server Statistics + + + + {/* CPU Metrics */} + {renderMetricCard( + , + "CPU Usage", + typeof metrics?.cpu?.percent === "number" + ? `${metrics.cpu.percent}%` + : "N/A", + typeof metrics?.cpu?.cores === "number" + ? `${metrics.cpu.cores} cores` + : "N/A", + "#60A5FA", + )} + + {/* Load Average */} + {metrics?.cpu?.load && ( + + + + + Load Average + + + + + + {metrics.cpu.load[0].toFixed(2)} + + + 1 min + + + + + {metrics.cpu.load[1].toFixed(2)} + + + 5 min + + + + + {metrics.cpu.load[2].toFixed(2)} + + + 15 min + + + + + )} + + {/* Memory Metrics */} + {renderMetricCard( + , + "Memory Usage", + typeof metrics?.memory?.percent === "number" + ? `${metrics.memory.percent}%` + : "N/A", + (() => { + const used = metrics?.memory?.usedGiB; + const total = metrics?.memory?.totalGiB; + if (typeof used === "number" && typeof total === "number") { + return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`; + } + return "N/A"; + })(), + "#34D399", + )} + + {/* Disk Metrics */} + {renderMetricCard( + , + "Disk Usage", + typeof metrics?.disk?.percent === "number" + ? `${metrics.disk.percent}%` + : "N/A", + (() => { + const used = metrics?.disk?.usedHuman; + const total = metrics?.disk?.totalHuman; + if (used && total) { + return `${used} / ${total}`; + } + return "N/A"; + })(), + "#F59E0B", + )} + + {/* Last Updated */} + + + + Last updated: {new Date(metrics?.lastChecked || "").toLocaleTimeString()} + + + + )} + + ); + }, +); + +export default ServerStats; diff --git a/app/Tabs/Sessions/Sessions.tsx b/app/Tabs/Sessions/Sessions.tsx index b38f820..b7fd8b2 100644 --- a/app/Tabs/Sessions/Sessions.tsx +++ b/app/Tabs/Sessions/Sessions.tsx @@ -21,14 +21,19 @@ import { useRouter } from "expo-router"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { Terminal, TerminalHandle } from "@/app/Tabs/Sessions/Terminal"; +import { ServerStats, ServerStatsHandle } from "@/app/Tabs/Sessions/ServerStats"; +import { FileManager, FileManagerHandle } from "@/app/Tabs/Sessions/FileManager"; import TabBar from "@/app/Tabs/Sessions/Navigation/TabBar"; -import CustomKeyboard from "@/app/Tabs/Sessions/CustomKeyboard"; +import BottomToolbar from "@/app/Tabs/Sessions/BottomToolbar"; import KeyboardBar from "@/app/Tabs/Sessions/KeyboardBar"; import { ArrowLeft } from "lucide-react-native"; +import { useOrientation } from "@/app/utils/orientation"; +import { getMaxKeyboardHeight } from "@/app/utils/responsive"; export default function Sessions() { const insets = useSafeAreaInsets(); const router = useRouter(); + const { height, isLandscape } = useOrientation(); const { sessions, activeSessionId, @@ -46,6 +51,12 @@ export default function Sessions() { const terminalRefs = useRef>>( {}, ); + const statsRefs = useRef>>( + {}, + ); + const fileManagerRefs = useRef>>( + {}, + ); const [activeModifiers, setActiveModifiers] = useState({ ctrl: false, alt: false, @@ -55,22 +66,85 @@ export default function Sessions() { ); const [keyboardType, setKeyboardType] = useState("default"); + // Calculate responsive keyboard heights and margins + const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); + const effectiveKeyboardHeight = isLandscape + ? Math.min(lastKeyboardHeight, maxKeyboardHeight) + : lastKeyboardHeight; + const currentKeyboardHeight = isLandscape + ? Math.min(keyboardHeight, maxKeyboardHeight) + : keyboardHeight; + + // Calculate bottom margins for content + const getBottomMargin = () => { + const tabBarHeight = 60; + const keyboardBarHeight = 50; + const baseMargin = tabBarHeight + keyboardBarHeight + 5; + + if (keyboardIntentionallyHiddenRef.current) { + return 126; + } + + if (isCustomKeyboardVisible) { + return effectiveKeyboardHeight + baseMargin; + } + + if (isKeyboardVisible && currentKeyboardHeight > 0) { + return currentKeyboardHeight + baseMargin; + } + + if (effectiveKeyboardHeight > 0) { + return effectiveKeyboardHeight + baseMargin; + } + + return baseMargin; + }; + useEffect(() => { - const map: Record> = { + const terminalMap: Record> = { ...terminalRefs.current, }; + const statsMap: Record> = { + ...statsRefs.current, + }; + const fileManagerMap: Record> = { + ...fileManagerRefs.current, + }; + sessions.forEach((s) => { - if (!map[s.id]) { - map[s.id] = + if (s.type === "terminal" && !terminalMap[s.id]) { + terminalMap[s.id] = React.createRef() as React.RefObject; + } else if (s.type === "stats" && !statsMap[s.id]) { + statsMap[s.id] = + React.createRef() as React.RefObject; + } else if (s.type === "filemanager" && !fileManagerMap[s.id]) { + fileManagerMap[s.id] = + React.createRef() as React.RefObject; } }); - Object.keys(map).forEach((id) => { - if (!sessions.find((s) => s.id === id)) { - delete map[id]; + + Object.keys(terminalMap).forEach((id) => { + if (!sessions.find((s) => s.id === id && s.type === "terminal")) { + delete terminalMap[id]; + } + }); + + Object.keys(statsMap).forEach((id) => { + if (!sessions.find((s) => s.id === id && s.type === "stats")) { + delete statsMap[id]; + } + }); + + Object.keys(fileManagerMap).forEach((id) => { + if (!sessions.find((s) => s.id === id && s.type === "filemanager")) { + delete fileManagerMap[id]; } }); - terminalRefs.current = map; + + terminalRefs.current = terminalMap; + statsRefs.current = statsMap; + fileManagerRefs.current = fileManagerMap; }, [sessions]); useFocusEffect( @@ -265,41 +339,72 @@ export default function Sessions() { 0 - ? keyboardHeight + 115 - : lastKeyboardHeight > 0 - ? lastKeyboardHeight + 115 - : 115, + marginBottom: getBottomMargin(), }} > - {sessions.map((session) => ( - handleTabClose(session.id)} - /> - ))} + {sessions.map((session) => { + if (session.type === "terminal") { + return ( + handleTabClose(session.id)} + /> + ); + } else if (session.type === "stats") { + return ( + handleTabClose(session.id)} + /> + ); + } else if (session.type === "filemanager") { + return ( + + + + ); + } + return null; + })} {sessions.length === 0 && ( @@ -397,31 +502,23 @@ export default function Sessions() { bottom: 0, left: 0, right: 0, - height: keyboardIntentionallyHiddenRef.current - ? 126 - : isCustomKeyboardVisible - ? lastKeyboardHeight + 115 - : isKeyboardVisible && keyboardHeight > 0 - ? keyboardHeight + 115 - : lastKeyboardHeight > 0 - ? lastKeyboardHeight + 115 - : 115, + height: getBottomMargin(), backgroundColor: "#09090b", zIndex: 999, }} /> )} - {sessions.length > 0 && ( + {sessions.length > 0 && activeSession?.type === "terminal" && ( 0 - ? keyboardHeight + ? effectiveKeyboardHeight + : isKeyboardVisible && currentKeyboardHeight > 0 + ? currentKeyboardHeight : 0, left: 0, right: 0, @@ -450,9 +547,9 @@ export default function Sessions() { bottom: keyboardIntentionallyHiddenRef.current ? 66 : isCustomKeyboardVisible - ? lastKeyboardHeight + 50 - : isKeyboardVisible && keyboardHeight > 0 - ? keyboardHeight + 50 + ? effectiveKeyboardHeight + 50 + : isKeyboardVisible && currentKeyboardHeight > 0 + ? currentKeyboardHeight + 50 : 50, left: 0, right: 0, @@ -475,7 +572,7 @@ export default function Sessions() { /> - {sessions.length > 0 && isCustomKeyboardVisible && ( + {sessions.length > 0 && isCustomKeyboardVisible && activeSession?.type === "terminal" && ( - () } isVisible={isCustomKeyboardVisible} - keyboardHeight={lastKeyboardHeight} + keyboardHeight={effectiveKeyboardHeight} isKeyboardIntentionallyHidden={ keyboardIntentionallyHiddenRef.current } + currentHostId={ + activeSession ? parseInt(activeSession.host.id.toString()) : undefined + } /> )} - {sessions.length > 0 && !isCustomKeyboardVisible && ( + {sessions.length > 0 && !isCustomKeyboardVisible && activeSession?.type === "terminal" && ( 0 ? keyboardHeight : 0, + bottom: currentKeyboardHeight > 0 ? currentKeyboardHeight : 0, left: 0, width: 1, height: 1, diff --git a/app/Tabs/Sessions/SnippetsBar.tsx b/app/Tabs/Sessions/SnippetsBar.tsx new file mode 100644 index 0000000..2107acd --- /dev/null +++ b/app/Tabs/Sessions/SnippetsBar.tsx @@ -0,0 +1,308 @@ +import React, { useState, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, +} from "react-native"; +import { TerminalHandle } from "./Terminal"; +import { getSnippets, getSnippetFolders } from "@/app/main-axios"; +import { showToast } from "@/app/utils/toast"; + +interface Snippet { + id: number; + name: string; + content: string; + folderId: number | null; + sortOrder: number; +} + +interface SnippetFolder { + id: number; + name: string; + color: string | null; + icon: string | null; + sortOrder: number; +} + +interface SnippetsBarProps { + terminalRef: React.RefObject; + isVisible: boolean; + height: number; +} + +export default function SnippetsBar({ + terminalRef, + isVisible, + height, +}: SnippetsBarProps) { + const [snippets, setSnippets] = useState([]); + const [folders, setFolders] = useState([]); + const [collapsedFolders, setCollapsedFolders] = useState>( + new Set() + ); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (isVisible) { + loadSnippets(); + } + }, [isVisible]); + + const loadSnippets = async () => { + try { + setLoading(true); + const [snippetsData, foldersData] = await Promise.all([ + getSnippets(), + getSnippetFolders(), + ]); + + setSnippets( + snippetsData.sort((a: Snippet, b: Snippet) => a.sortOrder - b.sortOrder) + ); + setFolders( + foldersData.sort( + (a: SnippetFolder, b: SnippetFolder) => a.sortOrder - b.sortOrder + ) + ); + } catch (error) { + showToast("Failed to load snippets", "error"); + } finally { + setLoading(false); + } + }; + + const executeSnippet = (snippet: Snippet) => { + if (terminalRef.current) { + terminalRef.current.sendInput(snippet.content + "\n"); + showToast(`Executed: ${snippet.name}`, "success"); + } + }; + + const toggleFolder = (folderId: number) => { + setCollapsedFolders((prev) => { + const newSet = new Set(prev); + if (newSet.has(folderId)) { + newSet.delete(folderId); + } else { + newSet.add(folderId); + } + return newSet; + }); + }; + + const getSnippetsInFolder = (folderId: number | null) => { + return snippets.filter((s) => s.folderId === folderId); + }; + + if (!isVisible) return null; + + if (loading) { + return ( + + + + ); + } + + const unfolderedSnippets = getSnippetsInFolder(null); + + return ( + + + Snippets + + + + + + + {unfolderedSnippets.map((snippet) => ( + executeSnippet(snippet)} + > + + {snippet.name} + + + ))} + + {folders.map((folder) => { + const folderSnippets = getSnippetsInFolder(folder.id); + const isCollapsed = collapsedFolders.has(folder.id); + + return ( + + toggleFolder(folder.id)} + > + + {folder.icon && ( + {folder.icon} + )} + + {folder.name} + + ({folderSnippets.length}) + + + {isCollapsed ? "▶" : "▼"} + + + + {!isCollapsed && + folderSnippets.map((snippet) => ( + executeSnippet(snippet)} + > + + {snippet.name} + + + ))} + + ); + })} + + {snippets.length === 0 && ( + + No snippets yet + + Create snippets in Settings + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#0e0e10", + borderTopWidth: 1.5, + borderTopColor: "#303032", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 12, + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: "#303032", + }, + headerText: { + fontSize: 14, + fontWeight: "600", + color: "#e5e5e7", + }, + refreshButton: { + padding: 4, + }, + refreshText: { + fontSize: 18, + color: "#9333ea", + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 8, + paddingVertical: 8, + }, + snippetItem: { + backgroundColor: "#18181b", + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 6, + marginBottom: 6, + borderWidth: 1, + borderColor: "#303032", + }, + snippetItemInFolder: { + backgroundColor: "#18181b", + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 6, + marginBottom: 6, + marginLeft: 16, + borderWidth: 1, + borderColor: "#303032", + }, + snippetName: { + fontSize: 13, + color: "#e5e5e7", + fontWeight: "500", + }, + folderContainer: { + marginBottom: 8, + }, + folderHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: "#18181b", + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 6, + marginBottom: 6, + borderWidth: 1, + borderColor: "#303032", + borderLeftWidth: 3, + borderLeftColor: "#9333ea", + }, + folderHeaderContent: { + flexDirection: "row", + alignItems: "center", + flex: 1, + }, + folderIcon: { + fontSize: 16, + marginRight: 8, + }, + folderName: { + fontSize: 14, + fontWeight: "600", + color: "#e5e5e7", + flex: 1, + }, + folderCount: { + fontSize: 12, + color: "#888", + marginLeft: 4, + }, + collapseIcon: { + fontSize: 10, + color: "#888", + marginLeft: 8, + }, + emptyContainer: { + paddingVertical: 32, + alignItems: "center", + }, + emptyText: { + fontSize: 14, + color: "#888", + fontWeight: "600", + }, + emptySubtext: { + fontSize: 12, + color: "#666", + marginTop: 4, + }, +}); diff --git a/app/Tabs/Sessions/Terminal.tsx b/app/Tabs/Sessions/Terminal.tsx index 110feb1..7cd75d8 100644 --- a/app/Tabs/Sessions/Terminal.tsx +++ b/app/Tabs/Sessions/Terminal.tsx @@ -11,13 +11,16 @@ import { Text, ActivityIndicator, Dimensions, - Platform, TouchableWithoutFeedback, Keyboard, TextInput, } from "react-native"; import { WebView } from "react-native-webview"; -import { getCurrentServerUrl, getCookie } from "../../main-axios"; +import { + getCurrentServerUrl, + getCookie, + logActivity, +} from "../../main-axios"; import { showToast } from "../../utils/toast"; import { useTerminalCustomization } from "../../contexts/TerminalCustomizationContext"; @@ -45,7 +48,7 @@ export type TerminalHandle = { fit: () => void; }; -export const Terminal = forwardRef( +const TerminalComponent = forwardRef( ({ hostConfig, isVisible, title = "Terminal", onClose }, ref) => { const webViewRef = useRef(null); const { config } = useTerminalCustomization(); @@ -64,6 +67,8 @@ export const Terminal = forwardRef( const connectionTimeoutRef = useRef | null>( null, ); + const hiddenInputRef = useRef(null); + const isComposingRef = useRef(false); useEffect(() => { const subscription = Dimensions.addEventListener( @@ -133,10 +138,14 @@ export const Terminal = forwardRef( `; } - // Use font size from context const baseFontSize = config.fontSize; - const terminalWidth = Math.floor(width / 8); - const terminalHeight = Math.floor(height / 16); + // Improved calculation based on font size + // Average monospace char width is roughly 0.6 * fontSize + // Line height is roughly 1.2 * fontSize + const charWidth = baseFontSize * 0.6; + const lineHeight = baseFontSize * 1.2; + const terminalWidth = Math.floor(width / charWidth); + const terminalHeight = Math.floor(height / lineHeight); return ` @@ -149,13 +158,43 @@ export const Terminal = forwardRef( +
@@ -523,6 +665,9 @@ export const Terminal = forwardRef( setIsRetrying(false); setIsConnected(true); setRetryCount(0); + + // Log terminal activity + logActivity("terminal", hostConfig.id).catch(() => {}); break; case "dataReceived": @@ -594,6 +739,14 @@ export const Terminal = forwardRef( } }, [hostConfig.id, currentHostId]); + useEffect(() => { + if (isVisible && isConnected && !showConnectingOverlay) { + setTimeout(() => { + focusTerminal(); + }, 300); + } + }, [isVisible, isConnected, showConnectingOverlay, focusTerminal]); + useEffect(() => { return () => { if (connectionTimeoutRef.current) { @@ -602,6 +755,25 @@ export const Terminal = forwardRef( }; }, []); + const handleNativeTextChange = useCallback((text: string) => { + if (!isComposingRef.current && text && webViewRef.current) { + try { + const escaped = JSON.stringify(text); + webViewRef.current.injectJavaScript( + `if (window.hiddenInput) { window.hiddenInput.value = ${escaped}; window.hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); } true;`, + ); + } catch (e) { + console.error("Failed to send input:", e); + } + } + }, []); + + const focusTerminal = useCallback(() => { + if (hiddenInputRef.current && isConnected && !showConnectingOverlay) { + hiddenInputRef.current.focus(); + } + }, [isConnected, showConnectingOverlay]); + return ( ( }} > - {(showConnectingOverlay || isRetrying) && ( @@ -746,4 +949,7 @@ export const Terminal = forwardRef( }, ); -export default Terminal; +TerminalComponent.displayName = "Terminal"; + +export { TerminalComponent as Terminal }; +export default TerminalComponent; diff --git a/app/Tabs/Settings/Settings.tsx b/app/Tabs/Settings/Settings.tsx index 5402597..a8cf7ee 100644 --- a/app/Tabs/Settings/Settings.tsx +++ b/app/Tabs/Settings/Settings.tsx @@ -4,9 +4,12 @@ import { useRouter } from "expo-router"; import { useAppContext } from "@/app/AppContext"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import { clearAuth, clearServerConfig, logoutUser } from "@/app/main-axios"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding } from "@/app/utils/responsive"; export default function Settings() { const router = useRouter(); + const { isLandscape } = useOrientation(); const { setAuthenticated, setShowLoginForm, @@ -17,6 +20,8 @@ export default function Settings() { const { clearAllSessions } = useTerminalSessions(); const insets = useSafeAreaInsets(); + const padding = getResponsivePadding(isLandscape); + const handleLogout = async () => { try { await logoutUser(); @@ -35,7 +40,7 @@ export default function Settings() { return ( - + Base font size for terminal text. The actual size will be adjusted - based on your screen width. + based on your screen width. This number will override the font size + you configured on a host in the Termix Web UI. {FONT_SIZE_OPTIONS.map((option) => ( diff --git a/app/contexts/TerminalSessionsContext.tsx b/app/contexts/TerminalSessionsContext.tsx index 7388447..5224c3d 100644 --- a/app/contexts/TerminalSessionsContext.tsx +++ b/app/contexts/TerminalSessionsContext.tsx @@ -17,16 +17,17 @@ export interface TerminalSession { title: string; isActive: boolean; createdAt: Date; + type: "terminal" | "stats" | "filemanager"; } interface TerminalSessionsContextType { sessions: TerminalSession[]; activeSessionId: string | null; - addSession: (host: SSHHost) => string; + addSession: (host: SSHHost, type?: "terminal" | "stats" | "filemanager") => string; removeSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void; clearAllSessions: () => void; - navigateToSessions: (host?: SSHHost) => void; + navigateToSessions: (host?: SSHHost, type?: "terminal" | "stats" | "filemanager") => void; isCustomKeyboardVisible: boolean; toggleCustomKeyboard: () => void; lastKeyboardHeight: number; @@ -64,24 +65,28 @@ export const TerminalSessionsProvider: React.FC< const keyboardIntentionallyHiddenRef = useRef(false); const [, forceUpdate] = useState({}); - const addSession = useCallback((host: SSHHost): string => { + const addSession = useCallback((host: SSHHost, type: "terminal" | "stats" | "filemanager" = "terminal"): string => { setSessions((prev) => { const existingSessions = prev.filter( - (session) => session.host.id === host.id, + (session) => session.host.id === host.id && session.type === type, ); - let title = host.name; + const typeLabel = type === "stats" ? "Stats" : type === "filemanager" ? "Files" : ""; + let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; if (existingSessions.length > 0) { - title = `${host.name} (${existingSessions.length + 1})`; + title = typeLabel + ? `${host.name} - ${typeLabel} (${existingSessions.length + 1})` + : `${host.name} (${existingSessions.length + 1})`; } - const sessionId = `${host.id}-${Date.now()}`; + const sessionId = `${host.id}-${type}-${Date.now()}`; const newSession: TerminalSession = { id: sessionId, host, title, isActive: true, createdAt: new Date(), + type, }; const updatedSessions = prev.map((session) => ({ @@ -109,8 +114,9 @@ export const TerminalSessionsProvider: React.FC< ); const hostId = sessionToRemove.host.id; + const sessionType = sessionToRemove.type; const sameHostSessions = updatedSessions.filter( - (session) => session.host.id === hostId, + (session) => session.host.id === hostId && session.type === sessionType, ); if (sameHostSessions.length > 0) { @@ -123,12 +129,14 @@ export const TerminalSessionsProvider: React.FC< (s) => s.id === session.id, ); if (sessionIndex !== -1) { + const typeLabel = session.type === "stats" ? "Stats" : session.type === "filemanager" ? "Files" : ""; + const baseName = typeLabel ? `${session.host.name} - ${typeLabel}` : session.host.name; updatedSessions[sessionIndex] = { ...session, title: index === 0 - ? session.host.name - : `${session.host.name} (${index + 1})`, + ? baseName + : `${baseName} (${index + 1})`, }; } }); @@ -165,9 +173,9 @@ export const TerminalSessionsProvider: React.FC< }, []); const navigateToSessions = useCallback( - (host?: SSHHost) => { + (host?: SSHHost, type: "terminal" | "stats" | "filemanager" = "terminal") => { if (host) { - addSession(host); + addSession(host, type); } router.push("/(tabs)/sessions"); }, diff --git a/app/main-axios.ts b/app/main-axios.ts index 9ce4d5d..aec0217 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -89,6 +89,10 @@ interface UserCount { count: number; } +interface OIDCAuthorize { + auth_url: string; +} + // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ @@ -186,7 +190,7 @@ function createApiInstance( config.headers["User-Agent"] = `Termix-Mobile/${platform.OS}`; } - return config; + return config;\ }); instance.interceptors.response.use( @@ -725,11 +729,21 @@ export async function createSSHHost(hostData: SSHHostData): Promise { keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], + jumpHosts: hostData.jumpHosts || [], + quickActions: hostData.quickActions || [], + statsConfig: hostData.statsConfig + ? typeof hostData.statsConfig === "string" + ? hostData.statsConfig + : JSON.stringify(hostData.statsConfig) + : null, + terminalConfig: hostData.terminalConfig || null, + forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), }; if (!submitData.enableTunnel) { @@ -781,11 +795,21 @@ export async function updateSSHHost( keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], + jumpHosts: hostData.jumpHosts || [], + quickActions: hostData.quickActions || [], + statsConfig: hostData.statsConfig + ? typeof hostData.statsConfig === "string" + ? hostData.statsConfig + : JSON.stringify(hostData.statsConfig) + : null, + terminalConfig: hostData.terminalConfig || null, + forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), }; if (!submitData.enableTunnel) { @@ -848,6 +872,65 @@ export async function getSSHHostById(hostId: number): Promise { } } +export async function exportSSHHostWithCredentials( + hostId: number, +): Promise { + try { + const response = await sshHostApi.get(`/db/host/${hostId}/export`); + return response.data; + } catch (error) { + handleApiError(error, "export SSH host with credentials"); + } +} + +// ============================================================================ +// SSH AUTOSTART MANAGEMENT +// ============================================================================ + +export async function enableAutoStart( + sshConfigId: number, +): Promise { + try { + const response = await sshHostApi.post("/autostart/enable", { + sshConfigId, + }); + return response.data; + } catch (error) { + handleApiError(error, "enable autostart"); + } +} + +export async function disableAutoStart( + sshConfigId: number, +): Promise { + try { + const response = await sshHostApi.delete("/autostart/disable", { + data: { sshConfigId }, + }); + return response.data; + } catch (error) { + handleApiError(error, "disable autostart"); + } +} + +export async function getAutoStartStatus(): Promise<{ + autostart_configs: Array<{ + sshConfigId: number; + host: string; + port: number; + username: string; + authType: string; + }>; + total_count: number; +}> { + try { + const response = await sshHostApi.get("/autostart/status"); + return response.data; + } catch (error) { + handleApiError(error, "fetch autostart status"); + } +} + // ============================================================================ // TUNNEL MANAGEMENT // ============================================================================ @@ -1029,6 +1112,7 @@ export async function connectSSH( authType?: string; credentialId?: number; userId?: string; + forceKeyboardInteractive?: boolean; }, ): Promise { try { @@ -1066,17 +1150,58 @@ export async function getSSHStatus( } } +export async function verifySSHTOTP( + sessionId: string, + totpCode: string, +): Promise { + try { + const response = await fileManagerApi.post("/ssh/connect-totp", { + sessionId, + totpCode, + }); + return response.data; + } catch (error) { + handleApiError(error, "verify SSH TOTP"); + } +} + +export async function keepSSHAlive(sessionId: string): Promise { + try { + const response = await fileManagerApi.post("/ssh/keepalive", { + sessionId, + }); + return response.data; + } catch (error) { + handleApiError(error, "SSH keepalive"); + } +} + export async function listSSHFiles( sessionId: string, path: string, -): Promise { +): Promise<{ files: any[]; path: string }> { try { const response = await fileManagerApi.get("/ssh/listFiles", { params: { sessionId, path }, }); - return response.data || []; + return response.data || { files: [], path }; } catch (error) { handleApiError(error, "list SSH files"); + return { files: [], path }; + } +} + +export async function identifySSHSymlink( + sessionId: string, + path: string, +): Promise<{ path: string; target: string; type: "directory" | "file" }> { + try { + const response = await fileManagerApi.get("/ssh/identifySymlink", { + params: { sessionId, path }, + }); + return response.data; + } catch (error) { + handleApiError(error, "identify SSH symlink"); } } @@ -1089,7 +1214,13 @@ export async function readSSHFile( params: { sessionId, path }, }); return response.data; - } catch (error) { + } catch (error: any) { + if (error?.response?.status === 404) { + const customError: any = new Error("File not found"); + customError.response = error.response; + customError.isFileNotFound = error.response.data?.fileNotFound || true; + throw customError; + } handleApiError(error, "read SSH file"); } } @@ -1232,416 +1363,497 @@ export async function renameSSHItem( return response.data; } catch (error) { handleApiError(error, "rename SSH item"); + throw error; } } -// ============================================================================ -// SERVER STATISTICS -// ============================================================================ - -export async function getAllServerStatuses(): Promise< - Record -> { +export async function downloadSSHFile( + sessionId: string, + filePath: string, + hostId?: number, + userId?: string, +): Promise { try { - const response = await statsApi.get("/status"); - return response.data || {}; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getRootBase(8085), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get("/status"); - return response.data || {}; - } catch (e) { - handleApiError(e, "fetch server statuses"); - } - } - handleApiError(error, "fetch server statuses"); + const response = await fileManagerApi.post("/ssh/downloadFile", { + sessionId, + path: filePath, + hostId, + userId, + }); + return response.data; + } catch (error) { + handleApiError(error, "download SSH file"); } } -export async function getServerStatusById(id: number): Promise { +export async function copySSHItem( + sessionId: string, + sourcePath: string, + targetDir: string, + hostId?: number, + userId?: string, +): Promise { try { - const response = await statsApi.get(`/status/${id}`); + const response = await fileManagerApi.post( + "/ssh/copyItem", + { + sessionId, + sourcePath, + targetDir, + hostId, + userId, + }, + { + timeout: 60000, + }, + ); return response.data; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getRootBase(8085), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get(`/status/${id}`); - return response.data; - } catch (e) { - handleApiError(e, "fetch server status"); - } - } - handleApiError(error, "fetch server status"); + } catch (error) { + handleApiError(error, "copy SSH item"); + throw error; } } -export async function getServerMetricsById(id: number): Promise { +export async function moveSSHItem( + sessionId: string, + oldPath: string, + newPath: string, + hostId?: number, + userId?: string, +): Promise { try { - const response = await statsApi.get(`/metrics/${id}`); + const response = await fileManagerApi.put( + "/ssh/moveItem", + { + sessionId, + oldPath, + newPath, + hostId, + userId, + }, + { + timeout: 60000, + }, + ); return response.data; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getRootBase(8085), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get(`/metrics/${id}`); - return response.data; - } catch (e) { - handleApiError(e, "fetch server metrics"); - } - } - handleApiError(error, "fetch server metrics"); + } catch (error) { + handleApiError(error, "move SSH item"); + throw error; } } -// ============================================================================ -// AUTHENTICATION -// ============================================================================ - -export async function registerUser( - username: string, - password: string, -): Promise { +export async function changeSSHPermissions( + sessionId: string, + path: string, + permissions: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string }> { try { - const response = await authApi.post("/users/create", { - username, - password, + fileLogger.info("Changing SSH file permissions", { + operation: "change_permissions", + sessionId, + path, + permissions, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/changePermissions", { + sessionId, + path, + permissions, + hostId, + userId, + }); + + fileLogger.success("SSH file permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions, }); + return response.data; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getSshBase(8081), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.post("/users/create", { - username, - password, - }); - return response.data; - } catch (e) { - handleApiError(e, "register user"); - } - } - handleApiError(error, "register user"); + } catch (error) { + fileLogger.error("Failed to change SSH file permissions", error, { + operation: "change_permissions", + sessionId, + path, + permissions, + }); + handleApiError(error, "change SSH permissions"); + throw error; } } -export async function loginUser( - username: string, - password: string, -): Promise { +export async function extractSSHArchive( + sessionId: string, + archivePath: string, + extractPath?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; extractPath: string }> { try { - const response = await authApi.post("/users/login", { username, password }); - - if (response.data.requires_totp) { - return { - ...response.data, - token: response.data.temp_token || "", - }; - } + fileLogger.info("Extracting archive", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); - let token = null; - const cookieHeader = response.headers["set-cookie"]; - if (cookieHeader && Array.isArray(cookieHeader)) { - for (const cookie of cookieHeader) { - if (cookie.startsWith("jwt=")) { - token = cookie.split("jwt=")[1].split(";")[0]; - break; - } - } - } + const response = await fileManagerApi.post("/ssh/extractArchive", { + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); - const result = { - ...response.data, - token: token || response.data.token, - }; + fileLogger.success("Archive extracted successfully", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: response.data.extractPath, + }); - if (result.token && !response.data.requires_totp) { - await AsyncStorage.setItem("jwt", result.token); - } - - return result; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getSshBase(8081), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.post("/users/login", { username, password }); - - if (response.data.requires_totp) { - return { - ...response.data, - token: response.data.temp_token || "", - }; - } - - let token = null; - const cookieHeader = response.headers["set-cookie"]; - if (cookieHeader && Array.isArray(cookieHeader)) { - for (const cookie of cookieHeader) { - if (cookie.startsWith("jwt=")) { - token = cookie.split("jwt=")[1].split(";")[0]; - break; - } - } - } - - const result = { - ...response.data, - token: token || response.data.token, - }; - - if (result.token && !response.data.requires_totp) { - await AsyncStorage.setItem("jwt", result.token); - } - - return result; - } catch (e) { - handleApiError(e, "login user"); - } - } - handleApiError(error, "login user"); - } -} - -export async function logoutUser(): Promise<{ - success: boolean; - message: string; -}> { - try { - const response = await authApi.post("/users/logout"); return response.data; } catch (error) { - handleApiError(error, "logout user"); + fileLogger.error("Failed to extract archive", error, { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + }); + handleApiError(error, "extract archive"); + throw error; } } -export async function getUserInfo(): Promise { +export async function compressSSHFiles( + sessionId: string, + paths: string[], + archiveName: string, + format?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; archivePath: string }> { try { - const response = await authApi.get("/users/me"); - return response.data; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getSshBase(8081), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get("/users/me"); - return response.data; - } catch (e) { - handleApiError(e, "fetch user info"); - } - } - handleApiError(error, "fetch user info"); - } -} + fileLogger.info("Compressing files", { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + hostId, + userId, + }); -export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> { - try { - const response = await authApi.get("/users/registration-allowed"); - return response.data; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getSshBase(8081), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get("/users/registration-allowed"); - return response.data; - } catch (e) { - handleApiError(e, "check registration status"); - } - } - handleApiError(error, "check registration status"); - } -} + const response = await fileManagerApi.post("/ssh/compressFiles", { + sessionId, + paths, + archiveName, + format: format || "zip", + hostId, + userId, + }); + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath: response.data.archivePath, + }); -export async function getUserCount(): Promise { - try { - const response = await authApi.get("/users/count"); return response.data; } catch (error) { - handleApiError(error, "fetch user count"); + fileLogger.error("Failed to compress files", error, { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + }); + handleApiError(error, "compress files"); + throw error; } } -export async function initiatePasswordReset(username: string): Promise { +// ============================================================================ +// FILE MANAGER DATA +// ============================================================================ + +export async function getRecentFiles(hostId: number): Promise { try { - const response = await authApi.post("/users/initiate-reset", { username }); + const response = await authApi.get("/ssh/file_manager/recent", { + params: { hostId }, + }); return response.data; } catch (error) { - handleApiError(error, "initiate password reset"); + handleApiError(error, "get recent files"); + throw error; } } -export async function verifyPasswordResetCode( - username: string, - resetCode: string, +export async function addRecentFile( + hostId: number, + path: string, + name?: string, ): Promise { try { - const response = await authApi.post("/users/verify-reset-code", { - username, - resetCode, + const response = await authApi.post("/ssh/file_manager/recent", { + hostId, + path, + name, }); return response.data; } catch (error) { - handleApiError(error, "verify reset code"); + handleApiError(error, "add recent file"); + throw error; } } -export async function completePasswordReset( - username: string, - tempToken: string, - newPassword: string, +export async function removeRecentFile( + hostId: number, + path: string, ): Promise { try { - const response = await authApi.post("/users/complete-reset", { - username, - tempToken, - newPassword, + const response = await authApi.delete("/ssh/file_manager/recent", { + data: { hostId, path }, }); return response.data; } catch (error) { - handleApiError(error, "complete password reset"); + handleApiError(error, "remove recent file"); + throw error; } } -// ============================================================================ -// USER MANAGEMENT -// ============================================================================ - -export async function getUserList(): Promise<{ users: UserInfo[] }> { +export async function getPinnedFiles(hostId: number): Promise { try { - const response = await authApi.get("/users/list"); + const response = await authApi.get("/ssh/file_manager/pinned", { + params: { hostId }, + }); return response.data; } catch (error) { - handleApiError(error, "fetch user list"); + handleApiError(error, "get pinned files"); + throw error; } } -export async function makeUserAdmin(username: string): Promise { +export async function addPinnedFile( + hostId: number, + path: string, + name?: string, +): Promise { try { - const response = await authApi.post("/users/make-admin", { username }); + const response = await authApi.post("/ssh/file_manager/pinned", { + hostId, + path, + name, + }); return response.data; } catch (error) { - handleApiError(error, "make user admin"); + handleApiError(error, "add pinned file"); + throw error; } } -export async function removeAdminStatus(username: string): Promise { +export async function removePinnedFile( + hostId: number, + path: string, +): Promise { try { - const response = await authApi.post("/users/remove-admin", { username }); + const response = await authApi.delete("/ssh/file_manager/pinned", { + data: { hostId, path }, + }); return response.data; } catch (error) { - handleApiError(error, "remove admin status"); + handleApiError(error, "remove pinned file"); + throw error; } } -export async function deleteUser(username: string): Promise { +export async function getFolderShortcuts(hostId: number): Promise { try { - const response = await authApi.delete("/users/delete-user", { - data: { username }, + const response = await authApi.get("/ssh/file_manager/shortcuts", { + params: { hostId }, }); return response.data; } catch (error) { - handleApiError(error, "delete user"); + handleApiError(error, "get folder shortcuts"); + throw error; } } -export async function deleteAccount(password: string): Promise { +export async function addFolderShortcut( + hostId: number, + path: string, + name?: string, +): Promise { try { - const response = await authApi.delete("/users/delete-account", { - data: { password }, + const response = await authApi.post("/ssh/file_manager/shortcuts", { + hostId, + path, + name, }); return response.data; } catch (error) { - handleApiError(error, "delete account"); + handleApiError(error, "add folder shortcut"); + throw error; } } -export async function updateRegistrationAllowed( - allowed: boolean, +export async function removeFolderShortcut( + hostId: number, + path: string, ): Promise { try { - const response = await authApi.patch("/users/registration-allowed", { - allowed, + const response = await authApi.delete("/ssh/file_manager/shortcuts", { + data: { hostId, path }, }); return response.data; } catch (error) { - handleApiError(error, "update registration allowed"); + handleApiError(error, "remove folder shortcut"); + throw error; } } // ============================================================================ -// ALERTS +// SERVER STATISTICS // ============================================================================ -export async function setupTOTP(): Promise<{ - secret: string; - qr_code: string; -}> { +export async function getAllServerStatuses(): Promise< + Record +> { try { - const response = await authApi.post("/users/totp/setup"); - return response.data; - } catch (error) { - handleApiError(error as AxiosError, "setup TOTP"); - throw error; + const response = await statsApi.get("/status"); + return response.data || {}; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getRootBase(8085), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.get("/status"); + return response.data || {}; + } catch (e) { + handleApiError(e, "fetch server statuses"); + } + } + handleApiError(error, "fetch server statuses"); } } -export async function enableTOTP( - totp_code: string, -): Promise<{ message: string; backup_codes: string[] }> { +export async function getServerStatusById(id: number): Promise { try { - const response = await authApi.post("/users/totp/enable", { totp_code }); + const response = await statsApi.get(`/status/${id}`); return response.data; - } catch (error) { - handleApiError(error as AxiosError, "enable TOTP"); - throw error; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getRootBase(8085), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.get(`/status/${id}`); + return response.data; + } catch (e) { + handleApiError(e, "fetch server status"); + } + } + handleApiError(error, "fetch server status"); } } -export async function disableTOTP( - password?: string, - totp_code?: string, -): Promise<{ message: string }> { +export async function getServerMetricsById(id: number): Promise { try { - const response = await authApi.post("/users/totp/disable", { + const response = await statsApi.get(`/metrics/${id}`); + return response.data; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getRootBase(8085), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.get(`/metrics/${id}`); + return response.data; + } catch (e) { + handleApiError(e, "fetch server metrics"); + } + } + handleApiError(error, "fetch server metrics"); + } +} + +export async function refreshServerPolling(): Promise { + try { + await statsApi.post("/refresh"); + } catch (error) { + console.warn("Failed to refresh server polling:", error); + } +} + +export async function notifyHostCreatedOrUpdated( + hostId: number, +): Promise { + try { + await statsApi.post("/host-updated", { hostId }); + } catch (error) { + console.warn("Failed to notify stats server of host update:", error); + } +} + +// ============================================================================ +// AUTHENTICATION +// ============================================================================ + +export async function registerUser( + username: string, + password: string, +): Promise { + try { + const response = await authApi.post("/users/create", { + username, password, - totp_code, }); return response.data; - } catch (error) { - handleApiError(error as AxiosError, "disable TOTP"); - throw error; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getSshBase(8081), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.post("/users/create", { + username, + password, + }); + return response.data; + } catch (e) { + handleApiError(e, "register user"); + } + } + handleApiError(error, "register user"); } } -export async function verifyTOTPLogin( - temp_token: string, - totp_code: string, +export async function loginUser( + username: string, + password: string, ): Promise { try { - const response = await authApi.post("/users/totp/verify-login", { - temp_token, - totp_code, - }); + const response = await authApi.post("/users/login", { username, password }); + + if (response.data.requires_totp) { + return { + ...response.data, + token: response.data.temp_token || "", + }; + } let token = null; const cookieHeader = response.headers["set-cookie"]; @@ -1659,35 +1871,33 @@ export async function verifyTOTPLogin( token: token || response.data.token, }; - if (result.token) { + if (result.token && !response.data.requires_totp) { await AsyncStorage.setItem("jwt", result.token); } return result; } catch (error: any) { - if (error?.response?.status === 404 || error?.response?.status === 500) { + if (error?.response?.status === 404) { try { const alt = axios.create({ baseURL: getSshBase(8081), headers: { "Content-Type": "application/json" }, }); + const response = await alt.post("/users/login", { username, password }); - const token = await getCookie("jwt"); - if (token) { - alt.defaults.headers.common["Authorization"] = `Bearer ${token}`; + if (response.data.requires_totp) { + return { + ...response.data, + token: response.data.temp_token || "", + }; } - const response = await alt.post("/users/totp/verify-login", { - temp_token, - totp_code, - }); - - let extractedToken = null; + let token = null; const cookieHeader = response.headers["set-cookie"]; if (cookieHeader && Array.isArray(cookieHeader)) { for (const cookie of cookieHeader) { if (cookie.startsWith("jwt=")) { - extractedToken = cookie.split("jwt=")[1].split(";")[0]; + token = cookie.split("jwt=")[1].split(";")[0]; break; } } @@ -1695,389 +1905,1328 @@ export async function verifyTOTPLogin( const result = { ...response.data, - token: extractedToken || response.data.token, + token: token || response.data.token, }; - if (result.token) { + if (result.token && !response.data.requires_totp) { await AsyncStorage.setItem("jwt", result.token); } return result; } catch (e) { - handleApiError(e, "verify TOTP login"); - throw e; + handleApiError(e, "login user"); } } - handleApiError(error as AxiosError, "verify TOTP login"); - throw error; + handleApiError(error, "login user"); } } -export async function generateBackupCodes( - password?: string, - totp_code?: string, -): Promise<{ backup_codes: string[] }> { +export async function logoutUser(): Promise<{ + success: boolean; + message: string; +}> { try { - const response = await authApi.post("/users/totp/backup-codes", { - password, - totp_code, - }); + const response = await authApi.post("/users/logout"); return response.data; } catch (error) { - handleApiError(error as AxiosError, "generate backup codes"); - throw error; + handleApiError(error, "logout user"); } } -export async function getUserAlerts( - userId: string, -): Promise<{ alerts: any[] }> { +export async function getUserInfo(): Promise { + try { + const response = await authApi.get("/users/me"); + return response.data; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getSshBase(8081), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.get("/users/me"); + return response.data; + } catch (e) { + handleApiError(e, "fetch user info"); + } + } + handleApiError(error, "fetch user info"); + } +} + +export async function unlockUserData( + password: string, +): Promise<{ success: boolean; message: string }> { try { - const response = await authApi.get(`/alerts/user/${userId}`); + const response = await authApi.post("/users/unlock-data", { password }); return response.data; } catch (error) { - handleApiError(error, "fetch user alerts"); + handleApiError(error, "unlock user data"); } } -export async function dismissAlert( - userId: string, - alertId: string, -): Promise { +export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> { + try { + const response = await authApi.get("/users/registration-allowed"); + return response.data; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getSshBase(8081), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.get("/users/registration-allowed"); + return response.data; + } catch (e) { + handleApiError(e, "check registration status"); + } + } + handleApiError(error, "check registration status"); + } +} + +export async function getPasswordLoginAllowed(): Promise<{ allowed: boolean }> { try { - const response = await authApi.post("/alerts/dismiss", { userId, alertId }); + const response = await authApi.get("/users/password-login-allowed"); return response.data; } catch (error) { - handleApiError(error, "dismiss alert"); + handleApiError(error, "check password login status"); } } -// ============================================================================ -// UPDATES & RELEASES -// ============================================================================ +export async function getOIDCConfig(): Promise { + try { + const response = await authApi.get("/users/oidc-config"); + return response.data; + } catch (error: any) { + console.warn( + "Failed to fetch OIDC config:", + error.response?.data?.error || error.message, + ); + return null; + } +} -export async function getReleasesRSS(perPage: number = 100): Promise { +export async function getAdminOIDCConfig(): Promise { try { - const response = await authApi.get(`/releases/rss?per_page=${perPage}`); + const response = await authApi.get("/users/oidc-config/admin"); return response.data; } catch (error) { - handleApiError(error, "fetch releases RSS"); + handleApiError(error, "fetch admin OIDC config"); } } -export async function getVersionInfo(): Promise { +export async function getSetupRequired(): Promise<{ setup_required: boolean }> { try { - const response = await authApi.get("/version"); + const response = await authApi.get("/users/setup-required"); return response.data; } catch (error) { - handleApiError(error, "fetch version info"); + handleApiError(error, "check setup status"); } } -export async function getLatestGitHubRelease(): Promise<{ - version: string; - tagName: string; - publishedAt: string; -} | null> { +export async function getUserCount(): Promise { try { - const response = await axios.get( - "https://api.github.com/repos/Termix-SSH/Mobile/releases/latest", - ); - const release = response.data; + const response = await authApi.get("/users/count"); + return response.data; + } catch (error) { + handleApiError(error, "fetch user count"); + } +} - const tagName = release.tag_name; - const versionMatch = tagName.match(/release-(\d+\.\d+\.\d+)(?:-tag)?/); +export async function initiatePasswordReset(username: string): Promise { + try { + const response = await authApi.post("/users/initiate-reset", { username }); + return response.data; + } catch (error) { + handleApiError(error, "initiate password reset"); + } +} - if (versionMatch) { - return { - version: versionMatch[1], - tagName: tagName, - publishedAt: release.published_at, - }; - } +export async function verifyPasswordResetCode( + username: string, + resetCode: string, +): Promise { + try { + const response = await authApi.post("/users/verify-reset-code", { + username, + resetCode, + }); + return response.data; + } catch (error) { + handleApiError(error, "verify reset code"); + } +} - return null; +export async function completePasswordReset( + username: string, + tempToken: string, + newPassword: string, +): Promise { + try { + const response = await authApi.post("/users/complete-reset", { + username, + tempToken, + newPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "complete password reset"); + } +} + +export async function changePassword( + oldPassword: string, + newPassword: string, +): Promise { + try { + const response = await authApi.post("/users/change-password", { + oldPassword, + newPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "change password"); + } +} + +export async function getOIDCAuthorizeUrl(): Promise { + try { + const response = await authApi.get("/users/oidc/authorize"); + return response.data; + } catch (error) { + handleApiError(error, "get OIDC authorize URL"); + } +} + +// ============================================================================ +// USER MANAGEMENT +// ============================================================================ + +export async function getUserList(): Promise<{ users: UserInfo[] }> { + try { + const response = await authApi.get("/users/list"); + return response.data; + } catch (error) { + handleApiError(error, "fetch user list"); + } +} + +export async function getSessions(): Promise<{ + sessions: Array<{ + id: string; + userId: string; + username?: string; + deviceType: string; + deviceInfo: string; + createdAt: string; + expiresAt: string; + lastActiveAt: string; + jwtToken: string; + isRevoked?: boolean; + }>; +}> { + try { + const response = await authApi.get("/users/sessions"); + return response.data; + } catch (error) { + handleApiError(error, "fetch sessions"); + } +} + +export async function revokeSession( + sessionId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.delete(`/users/sessions/${sessionId}`); + return response.data; + } catch (error) { + handleApiError(error, "revoke session"); + } +} + +export async function revokeAllUserSessions( + userId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post("/users/sessions/revoke-all", { + targetUserId: userId, + exceptCurrent: false, + }); + return response.data; + } catch (error) { + handleApiError(error, "revoke all user sessions"); + } +} + +export async function makeUserAdmin(username: string): Promise { + try { + const response = await authApi.post("/users/make-admin", { username }); + return response.data; + } catch (error) { + handleApiError(error, "make user admin"); + } +} + +export async function removeAdminStatus(username: string): Promise { + try { + const response = await authApi.post("/users/remove-admin", { username }); + return response.data; + } catch (error) { + handleApiError(error, "remove admin status"); + } +} + +export async function deleteUser(username: string): Promise { + try { + const response = await authApi.delete("/users/delete-user", { + data: { username }, + }); + return response.data; + } catch (error) { + handleApiError(error, "delete user"); + } +} + +export async function deleteAccount(password: string): Promise { + try { + const response = await authApi.delete("/users/delete-account", { + data: { password }, + }); + return response.data; + } catch (error) { + handleApiError(error, "delete account"); + } +} + +export async function updateRegistrationAllowed( + allowed: boolean, +): Promise { + try { + const response = await authApi.patch("/users/registration-allowed", { + allowed, + }); + return response.data; + } catch (error) { + handleApiError(error, "update registration allowed"); + } +} + +export async function updatePasswordLoginAllowed( + allowed: boolean, +): Promise<{ allowed: boolean }> { + try { + const response = await authApi.patch("/users/password-login-allowed", { + allowed, + }); + return response.data; + } catch (error) { + handleApiError(error, "update password login allowed"); + } +} + +export async function updateOIDCConfig(config: any): Promise { + try { + const response = await authApi.post("/users/oidc-config", config); + return response.data; + } catch (error) { + handleApiError(error, "update OIDC config"); + } +} + +export async function disableOIDCConfig(): Promise { + try { + const response = await authApi.delete("/users/oidc-config"); + return response.data; + } catch (error) { + handleApiError(error, "disable OIDC config"); + } +} + +// ============================================================================ +// ALERTS +// ============================================================================ + +export async function setupTOTP(): Promise<{ + secret: string; + qr_code: string; +}> { + try { + const response = await authApi.post("/users/totp/setup"); + return response.data; + } catch (error) { + handleApiError(error as AxiosError, "setup TOTP"); + throw error; + } +} + +export async function enableTOTP( + totp_code: string, +): Promise<{ message: string; backup_codes: string[] }> { + try { + const response = await authApi.post("/users/totp/enable", { totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError, "enable TOTP"); + throw error; + } +} + +export async function disableTOTP( + password?: string, + totp_code?: string, +): Promise<{ message: string }> { + try { + const response = await authApi.post("/users/totp/disable", { + password, + totp_code, + }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError, "disable TOTP"); + throw error; + } +} + +export async function verifyTOTPLogin( + temp_token: string, + totp_code: string, +): Promise { + try { + const response = await authApi.post("/users/totp/verify-login", { + temp_token, + totp_code, + }); + + let token = null; + const cookieHeader = response.headers["set-cookie"]; + if (cookieHeader && Array.isArray(cookieHeader)) { + for (const cookie of cookieHeader) { + if (cookie.startsWith("jwt=")) { + token = cookie.split("jwt=")[1].split(";")[0]; + break; + } + } + } + + const result = { + ...response.data, + token: token || response.data.token, + }; + + if (result.token) { + await AsyncStorage.setItem("jwt", result.token); + } + + return result; + } catch (error: any) { + if (error?.response?.status === 404 || error?.response?.status === 500) { + try { + const alt = axios.create({ + baseURL: getSshBase(8081), + headers: { "Content-Type": "application/json" }, + }); + + const token = await getCookie("jwt"); + if (token) { + alt.defaults.headers.common["Authorization"] = `Bearer ${token}`; + } + + const response = await alt.post("/users/totp/verify-login", { + temp_token, + totp_code, + }); + + let extractedToken = null; + const cookieHeader = response.headers["set-cookie"]; + if (cookieHeader && Array.isArray(cookieHeader)) { + for (const cookie of cookieHeader) { + if (cookie.startsWith("jwt=")) { + extractedToken = cookie.split("jwt=")[1].split(";")[0]; + break; + } + } + } + + const result = { + ...response.data, + token: extractedToken || response.data.token, + }; + + if (result.token) { + await AsyncStorage.setItem("jwt", result.token); + } + + return result; + } catch (e) { + handleApiError(e, "verify TOTP login"); + throw e; + } + } + handleApiError(error as AxiosError, "verify TOTP login"); + throw error; + } +} + +export async function generateBackupCodes( + password?: string, + totp_code?: string, +): Promise<{ backup_codes: string[] }> { + try { + const response = await authApi.post("/users/totp/backup-codes", { + password, + totp_code, + }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError, "generate backup codes"); + throw error; + } +} + +export async function getUserAlerts(): Promise<{ alerts: any[] }> { + try { + const response = await authApi.get(`/alerts`); + return response.data; + } catch (error) { + handleApiError(error, "fetch user alerts"); + } +} + +export async function dismissAlert(alertId: string): Promise { + try { + const response = await authApi.post("/alerts/dismiss", { alertId }); + return response.data; + } catch (error) { + handleApiError(error, "dismiss alert"); + } +} + +// ============================================================================ +// UPDATES & RELEASES +// ============================================================================ + +export async function getReleasesRSS(perPage: number = 100): Promise { + try { + const response = await authApi.get(`/releases/rss?per_page=${perPage}`); + return response.data; + } catch (error) { + handleApiError(error, "fetch releases RSS"); + } +} + +export async function getVersionInfo(): Promise { + try { + const response = await authApi.get("/version"); + return response.data; + } catch (error) { + handleApiError(error, "fetch version info"); + } +} + +export async function getLatestGitHubRelease(): Promise<{ + version: string; + tagName: string; + publishedAt: string; +} | null> { + try { + const response = await axios.get( + "https://api.github.com/repos/Termix-SSH/Mobile/releases/latest", + ); + const release = response.data; + + const tagName = release.tag_name; + const versionMatch = tagName.match(/release-(\d+\.\d+\.\d+)(?:-tag)?/); + + if (versionMatch) { + return { + version: versionMatch[1], + tagName: tagName, + publishedAt: release.published_at, + }; + } + + return null; + } catch (error) { + return null; + } +} + +// ============================================================================ +// DATABASE HEALTH +// ============================================================================ + +export async function getDatabaseHealth(): Promise { + try { + const response = await authApi.get("/users/db-health"); + return response.data; + } catch (error) { + handleApiError(error, "check database health"); + } +} + +// ============================================================================ +// SSH CREDENTIALS MANAGEMENT +// ============================================================================ + +export async function getCredentials(): Promise { + try { + const response = await authApi.get("/credentials"); + return response.data; + } catch (error) { + handleApiError(error, "fetch credentials"); + } +} + +export async function getCredentialDetails(credentialId: number): Promise { + try { + const response = await authApi.get(`/credentials/${credentialId}`); + return response.data; + } catch (error) { + handleApiError(error, "fetch credential details"); + } +} + +export async function createCredential(credentialData: any): Promise { + try { + const response = await authApi.post("/credentials", credentialData); + return response.data; + } catch (error) { + handleApiError(error, "create credential"); + } +} + +export async function updateCredential( + credentialId: number, + credentialData: any, +): Promise { + try { + const response = await authApi.put( + `/credentials/${credentialId}`, + credentialData, + ); + return response.data; + } catch (error) { + handleApiError(error, "update credential"); + } +} + +export async function deleteCredential(credentialId: number): Promise { + try { + const response = await authApi.delete(`/credentials/${credentialId}`); + return response.data; + } catch (error) { + handleApiError(error, "delete credential"); + } +} + +export async function getCredentialHosts(credentialId: number): Promise { + try { + const response = await authApi.get(`/credentials/${credentialId}/hosts`); + return response.data; + } catch (error) { + handleApiError(error, "fetch credential hosts"); + } +} + +export async function getCredentialFolders(): Promise { + try { + const response = await authApi.get("/credentials/folders"); + return response.data; + } catch (error) { + handleApiError(error, "fetch credential folders"); + } +} + +// Get SSH host with resolved credentials +export async function getSSHHostWithCredentials(hostId: number): Promise { + try { + const response = await sshHostApi.get( + `/db/host/${hostId}/with-credentials`, + ); + return response.data; + } catch (error) { + handleApiError(error, "fetch SSH host with credentials"); + } +} + +// Apply credential to SSH host +export async function applyCredentialToHost( + hostId: number, + credentialId: number, +): Promise { + try { + const response = await sshHostApi.post( + `/db/host/${hostId}/apply-credential`, + { credentialId }, + ); + return response.data; + } catch (error) { + handleApiError(error, "apply credential to host"); + } +} + +// Remove credential from SSH host +export async function removeCredentialFromHost(hostId: number): Promise { + try { + const response = await sshHostApi.delete(`/db/host/${hostId}/credential`); + return response.data; + } catch (error) { + handleApiError(error, "remove credential from host"); + } +} + +// Migrate host to managed credential +export async function migrateHostToCredential( + hostId: number, + credentialName: string, +): Promise { + try { + const response = await sshHostApi.post( + `/db/host/${hostId}/migrate-to-credential`, + { credentialName }, + ); + return response.data; + } catch (error) { + handleApiError(error, "migrate host to credential"); + } +} + +// ============================================================================ +// TERMINAL WEBSOCKET CONNECTION +// ============================================================================ + +export async function createTerminalWebSocket(): Promise { + try { + const serverUrl = getCurrentServerUrl(); + + if (!serverUrl) { + return null; + } + + const jwtToken = await getCookie("jwt"); + if (!jwtToken || jwtToken.trim() === "") { + return null; + } + + const wsProtocol = serverUrl.startsWith("https://") ? "wss://" : "ws://"; + const wsHost = serverUrl.replace(/^https?:\/\//, ""); + + const cleanHost = wsHost.replace(/\/$/, ""); + const wsUrl = `${wsProtocol}${cleanHost}/ssh/websocket/?token=${encodeURIComponent(jwtToken)}`; + + return new WebSocket(wsUrl); + } catch (error) { + return null; + } +} + +export function connectToTerminalHost( + ws: WebSocket, + hostConfig: any, + cols: number, + rows: number, +): void { + if (ws.readyState === WebSocket.OPEN) { + const connectMessage = { + type: "connectToHost", + data: { + cols, + rows, + hostConfig, + }, + }; + + ws.send(JSON.stringify(connectMessage)); + } else { + } +} + +export function sendTerminalInput(ws: WebSocket, input: string): void { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data: input })); + } +} + +export function sendTerminalResize( + ws: WebSocket, + cols: number, + rows: number, +): void { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", data: { cols, rows } })); + } +} + +// ============================================================================ +// SSH FOLDER MANAGEMENT +// ============================================================================ + +export async function getFoldersWithStats(): Promise { + try { + const token = await getCookie("jwt"); + + const tryFetch = async (baseUrl: string) => { + const cleanBase = baseUrl.replace(/\/$/, ""); + const tempInstance = axios.create({ + baseURL: cleanBase, + timeout: 10000, + headers: { + Accept: "application/json", + "User-Agent": "Termix-Mobile", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + try { + const response = await tempInstance.get("/ssh/folders"); + return response.data; + } catch (err: any) { + if (err.response?.status === 404) { + return null; + } + throw err; + } + }; + + const sshBase = getSshBase(8081); + let data = await tryFetch(sshBase); + + if (data === null) { + const rootBase = getRootBase(8081); + data = await tryFetch(rootBase); + } + return data || []; + } catch (error) { + return []; + } +} + +export async function renameFolder( + oldName: string, + newName: string, +): Promise { + try { + const response = await authApi.put("/ssh/folders/rename", { + oldName, + newName, + }); + return response.data; + } catch (error) { + handleApiError(error, "rename folder"); + } +} + +export async function getSSHFolders(): Promise { + try { + sshLogger.info("Fetching SSH folders", { + operation: "fetch_ssh_folders", + }); + + const response = await authApi.get("/ssh/folders"); + + sshLogger.success("SSH folders fetched successfully", { + operation: "fetch_ssh_folders", + count: response.data.length, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to fetch SSH folders", error, { + operation: "fetch_ssh_folders", + }); + handleApiError(error, "fetch SSH folders"); + throw error; + } +} + +export async function updateFolderMetadata( + name: string, + color?: string, + icon?: string, +): Promise { + try { + sshLogger.info("Updating folder metadata", { + operation: "update_folder_metadata", + name, + color, + icon, + }); + + await authApi.put("/ssh/folders/metadata", { + name, + color, + icon, + }); + + sshLogger.success("Folder metadata updated successfully", { + operation: "update_folder_metadata", + name, + }); + } catch (error) { + sshLogger.error("Failed to update folder metadata", error, { + operation: "update_folder_metadata", + name, + }); + handleApiError(error, "update folder metadata"); + throw error; + } +} + +export async function deleteAllHostsInFolder( + folderName: string, +): Promise<{ deletedCount: number }> { + try { + sshLogger.info("Deleting all hosts in folder", { + operation: "delete_folder_hosts", + folderName, + }); + + const response = await authApi.delete( + `/ssh/folders/${encodeURIComponent(folderName)}/hosts`, + ); + + sshLogger.success("All hosts in folder deleted successfully", { + operation: "delete_folder_hosts", + folderName, + deletedCount: response.data.deletedCount, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to delete hosts in folder", error, { + operation: "delete_folder_hosts", + folderName, + }); + handleApiError(error, "delete hosts in folder"); + throw error; + } +} + +export async function renameCredentialFolder( + oldName: string, + newName: string, +): Promise { + try { + const response = await authApi.put("/credentials/folders/rename", { + oldName, + newName, + }); + return response.data; + } catch (error) { + handleApiError(error, "rename credential folder"); + throw error; + } +} + +export async function detectKeyType( + privateKey: string, + keyPassword?: string, +): Promise { + try { + const response = await authApi.post("/credentials/detect-key-type", { + privateKey, + keyPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "detect key type"); + throw error; + } +} + +export async function detectPublicKeyType(publicKey: string): Promise { + try { + const response = await authApi.post("/credentials/detect-public-key-type", { + publicKey, + }); + return response.data; + } catch (error) { + handleApiError(error, "detect public key type"); + throw error; + } +} + +export async function validateKeyPair( + privateKey: string, + publicKey: string, + keyPassword?: string, +): Promise { + try { + const response = await authApi.post("/credentials/validate-key-pair", { + privateKey, + publicKey, + keyPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "validate key pair"); + throw error; + } +} + +export async function generatePublicKeyFromPrivate( + privateKey: string, + keyPassword?: string, +): Promise { + try { + const response = await authApi.post("/credentials/generate-public-key", { + privateKey, + keyPassword, + }); + return response.data; } catch (error) { - return null; + handleApiError(error, "generate public key from private key"); + throw error; } } -// ============================================================================ -// DATABASE HEALTH -// ============================================================================ +export async function generateKeyPair( + keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", + keySize?: number, + passphrase?: string, +): Promise { + try { + const response = await authApi.post("/credentials/generate-key-pair", { + keyType, + keySize, + passphrase, + }); + return response.data; + } catch (error) { + handleApiError(error, "generate SSH key pair"); + throw error; + } +} -export async function getDatabaseHealth(): Promise { +export async function deployCredentialToHost( + credentialId: number, + targetHostId: number, +): Promise { try { - const response = await authApi.get("/users/db-health"); + const response = await authApi.post( + `/credentials/${credentialId}/deploy-to-host`, + { targetHostId }, + ); return response.data; } catch (error) { - handleApiError(error, "check database health"); + handleApiError(error, "deploy credential to host"); + throw error; } } // ============================================================================ -// SSH CREDENTIALS MANAGEMENT +// SNIPPETS API // ============================================================================ -export async function getCredentials(): Promise { +export async function getSnippets(): Promise { try { - const response = await authApi.get("/credentials"); + const response = await authApi.get("/snippets"); return response.data; } catch (error) { - handleApiError(error, "fetch credentials"); + handleApiError(error, "fetch snippets"); + throw error; } } -export async function getCredentialDetails(credentialId: number): Promise { +export async function createSnippet(snippetData: any): Promise { try { - const response = await authApi.get(`/credentials/${credentialId}`); + const response = await authApi.post("/snippets", snippetData); return response.data; } catch (error) { - handleApiError(error, "fetch credential details"); + handleApiError(error, "create snippet"); + throw error; } } -export async function createCredential(credentialData: any): Promise { +export async function updateSnippet( + snippetId: number, + snippetData: any, +): Promise { try { - const response = await authApi.post("/credentials", credentialData); + const response = await authApi.put(`/snippets/${snippetId}`, snippetData); return response.data; } catch (error) { - handleApiError(error, "create credential"); + handleApiError(error, "update snippet"); + throw error; } } -export async function updateCredential( - credentialId: number, - credentialData: any, -): Promise { +export async function deleteSnippet(snippetId: number): Promise { try { - const response = await authApi.put( - `/credentials/${credentialId}`, - credentialData, - ); + const response = await authApi.delete(`/snippets/${snippetId}`); return response.data; } catch (error) { - handleApiError(error, "update credential"); + handleApiError(error, "delete snippet"); + throw error; } } -export async function deleteCredential(credentialId: number): Promise { +export async function executeSnippet( + snippetId: number, + hostId: number, +): Promise<{ success: boolean; output: string; error?: string }> { try { - const response = await authApi.delete(`/credentials/${credentialId}`); + const response = await authApi.post("/snippets/execute", { + snippetId, + hostId, + }); return response.data; } catch (error) { - handleApiError(error, "delete credential"); + handleApiError(error, "execute snippet"); + throw error; } } -export async function getCredentialHosts(credentialId: number): Promise { +export async function reorderSnippets( + snippets: Array<{ id: number; order: number; folder?: string }>, +): Promise<{ success: boolean; updated: number }> { try { - const response = await authApi.get(`/credentials/${credentialId}/hosts`); + const response = await authApi.put("/snippets/reorder", { snippets }); return response.data; } catch (error) { - handleApiError(error, "fetch credential hosts"); + handleApiError(error, "reorder snippets"); + throw error; } } -export async function getCredentialFolders(): Promise { +export async function getSnippetFolders(): Promise { try { - const response = await authApi.get("/credentials/folders"); + const response = await authApi.get("/snippets/folders"); return response.data; } catch (error) { - handleApiError(error, "fetch credential folders"); + handleApiError(error, "fetch snippet folders"); + throw error; } } -// Get SSH host with resolved credentials -export async function getSSHHostWithCredentials(hostId: number): Promise { +export async function createSnippetFolder(folderData: { + name: string; + color?: string; + icon?: string; +}): Promise { try { - const response = await sshHostApi.get( - `/db/host/${hostId}/with-credentials`, - ); + const response = await authApi.post("/snippets/folders", folderData); return response.data; } catch (error) { - handleApiError(error, "fetch SSH host with credentials"); + handleApiError(error, "create snippet folder"); + throw error; } } -// Apply credential to SSH host -export async function applyCredentialToHost( - hostId: number, - credentialId: number, +export async function updateSnippetFolderMetadata( + folderName: string, + metadata: { color?: string; icon?: string }, ): Promise { try { - const response = await sshHostApi.post( - `/db/host/${hostId}/apply-credential`, - { credentialId }, + const response = await authApi.put( + `/snippets/folders/${encodeURIComponent(folderName)}/metadata`, + metadata, ); return response.data; } catch (error) { - handleApiError(error, "apply credential to host"); + handleApiError(error, "update snippet folder metadata"); + throw error; } } -// Remove credential from SSH host -export async function removeCredentialFromHost(hostId: number): Promise { +export async function renameSnippetFolder( + oldName: string, + newName: string, +): Promise<{ success: boolean; oldName: string; newName: string }> { try { - const response = await sshHostApi.delete(`/db/host/${hostId}/credential`); + const response = await authApi.put("/snippets/folders/rename", { + oldName, + newName, + }); return response.data; } catch (error) { - handleApiError(error, "remove credential from host"); + handleApiError(error, "rename snippet folder"); + throw error; } } -// Migrate host to managed credential -export async function migrateHostToCredential( - hostId: number, - credentialName: string, -): Promise { +export async function deleteSnippetFolder( + folderName: string, +): Promise<{ success: boolean }> { try { - const response = await sshHostApi.post( - `/db/host/${hostId}/migrate-to-credential`, - { credentialName }, + const response = await authApi.delete( + `/snippets/folders/${encodeURIComponent(folderName)}`, ); return response.data; } catch (error) { - handleApiError(error, "migrate host to credential"); + handleApiError(error, "delete snippet folder"); + throw error; } } // ============================================================================ -// TERMINAL WEBSOCKET CONNECTION +// HOMEPAGE API // ============================================================================ -export async function createTerminalWebSocket(): Promise { - try { - const serverUrl = getCurrentServerUrl(); - - if (!serverUrl) { - return null; - } - - const jwtToken = await getCookie("jwt"); - if (!jwtToken || jwtToken.trim() === "") { - return null; - } - - const wsProtocol = serverUrl.startsWith("https://") ? "wss://" : "ws://"; - const wsHost = serverUrl.replace(/^https?:\/\//, ""); +export interface UptimeInfo { + uptimeMs: number; + uptimeSeconds: number; + formatted: string; +} - const cleanHost = wsHost.replace(/\/$/, ""); - const wsUrl = `${wsProtocol}${cleanHost}/ssh/websocket/?token=${encodeURIComponent(jwtToken)}`; +export interface RecentActivityItem { + id: number; + userId: string; + type: "terminal" | "file_manager"; + hostId: number; + hostName: string; + timestamp: string; +} - return new WebSocket(wsUrl); +export async function getUptime(): Promise { + try { + const response = await authApi.get("/uptime"); + return response.data; } catch (error) { - return null; + handleApiError(error, "fetch uptime"); + throw error; } } -export function connectToTerminalHost( - ws: WebSocket, - hostConfig: any, - cols: number, - rows: number, -): void { - if (ws.readyState === WebSocket.OPEN) { - const connectMessage = { - type: "connectToHost", - data: { - cols, - rows, - hostConfig, - }, - }; - - ws.send(JSON.stringify(connectMessage)); - } else { +export async function getRecentActivity( + limit?: number, +): Promise { + try { + const response = await authApi.get("/activity/recent", { + params: { limit }, + }); + return response.data; + } catch (error) { + handleApiError(error, "fetch recent activity"); + throw error; } } -export function sendTerminalInput(ws: WebSocket, input: string): void { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "input", data: input })); +export async function logActivity( + type: "terminal" | "file_manager", + hostId: number, + hostName: string, +): Promise<{ message: string; id: number | string }> { + try { + const response = await authApi.post("/activity/log", { + type, + hostId, + hostName, + }); + return response.data; + } catch (error) { + handleApiError(error, "log activity"); + throw error; } } -export function sendTerminalResize( - ws: WebSocket, - cols: number, - rows: number, -): void { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "resize", data: { cols, rows } })); +export async function resetRecentActivity(): Promise<{ message: string }> { + try { + const response = await authApi.delete("/activity/reset"); + return response.data; + } catch (error) { + handleApiError(error, "reset recent activity"); + throw error; } } // ============================================================================ -// SSH FOLDER MANAGEMENT +// COMMAND HISTORY API // ============================================================================ -export async function getFoldersWithStats(): Promise { +export async function saveCommandToHistory( + hostId: number, + command: string, +): Promise<{ id: number; command: string; executedAt: string }> { try { - const token = await getCookie("jwt"); - - const tryFetch = async (baseUrl: string) => { - const cleanBase = baseUrl.replace(/\/$/, ""); - const tempInstance = axios.create({ - baseURL: cleanBase, - timeout: 10000, - headers: { - Accept: "application/json", - "User-Agent": "Termix-Mobile", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); + const response = await authApi.post("/terminal/command_history", { + hostId, + command, + }); + return response.data; + } catch (error) { + handleApiError(error, "save command to history"); + throw error; + } +} - try { - const response = await tempInstance.get("/ssh/folders"); - return response.data; - } catch (err: any) { - if (err.response?.status === 404) { - return null; - } - throw err; - } - }; +export async function getCommandHistory( + hostId: number, + limit: number = 100, +): Promise { + try { + const response = await authApi.get(`/terminal/command_history/${hostId}`, { + params: { limit }, + }); + return response.data; + } catch (error) { + handleApiError(error, "fetch command history"); + throw error; + } +} - const sshBase = getSshBase(8081); - let data = await tryFetch(sshBase); +export async function deleteCommandFromHistory( + hostId: number, + command: string, +): Promise<{ success: boolean }> { + try { + const response = await authApi.post("/terminal/command_history/delete", { + hostId, + command, + }); + return response.data; + } catch (error) { + handleApiError(error, "delete command from history"); + throw error; + } +} - if (data === null) { - const rootBase = getRootBase(8081); - data = await tryFetch(rootBase); - } - return data || []; +export async function clearCommandHistory( + hostId: number, +): Promise<{ success: boolean }> { + try { + const response = await authApi.delete( + `/terminal/command_history/${hostId}`, + ); + return response.data; } catch (error) { - return []; + handleApiError(error, "clear command history"); + throw error; } } -export async function renameFolder( - oldName: string, - newName: string, -): Promise { +// ============================================================================ +// OIDC ACCOUNT LINKING +// ============================================================================ + +export async function linkOIDCToPasswordAccount( + oidcUserId: string, + targetUsername: string, +): Promise<{ success: boolean; message: string }> { try { - const response = await authApi.put("/ssh/folders/rename", { - oldName, - newName, + const response = await authApi.post("/users/link-oidc-to-password", { + oidcUserId, + targetUsername, }); return response.data; } catch (error) { - handleApiError(error, "rename folder"); + handleApiError(error, "link OIDC account to password account"); + throw error; } } -export async function renameCredentialFolder( - oldName: string, - newName: string, -): Promise { +export async function unlinkOIDCFromPasswordAccount( + userId: string, +): Promise<{ success: boolean; message: string }> { try { - const response = await authApi.put("/credentials/folders/rename", { - oldName, - newName, + const response = await authApi.post("/users/unlink-oidc-from-password", { + userId, }); return response.data; } catch (error) { - handleApiError(error, "rename credential folder"); + handleApiError(error, "unlink OIDC from password account"); + throw error; } } diff --git a/app/utils/orientation.ts b/app/utils/orientation.ts new file mode 100644 index 0000000..13c2cb9 --- /dev/null +++ b/app/utils/orientation.ts @@ -0,0 +1,45 @@ +import { useWindowDimensions } from 'react-native'; + +export type Orientation = 'portrait' | 'landscape'; + +/** + * Hook to get current orientation and dimensions + */ +export function useOrientation() { + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; + const orientation: Orientation = isLandscape ? 'landscape' : 'portrait'; + + return { + width, + height, + isLandscape, + isPortrait: !isLandscape, + orientation, + }; +} + +/** + * Get responsive value based on orientation + */ +export function getResponsiveValue( + portraitValue: T, + landscapeValue: T, + isLandscape: boolean +): T { + return isLandscape ? landscapeValue : portraitValue; +} + +/** + * Get percentage of dimension + */ +export function percentOf(dimension: number, percent: number): number { + return (dimension * percent) / 100; +} + +/** + * Clamp value between min and max + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/app/utils/responsive.ts b/app/utils/responsive.ts new file mode 100644 index 0000000..a79eb6a --- /dev/null +++ b/app/utils/responsive.ts @@ -0,0 +1,57 @@ +/** + * Responsive utility functions for adaptive layouts + */ + +/** + * Get number of columns based on screen width and orientation + */ +export function getColumnCount(width: number, isLandscape: boolean, itemMinWidth: number = 300): number { + if (!isLandscape) return 1; + + const columns = Math.floor(width / itemMinWidth); + return Math.max(2, Math.min(columns, 3)); // Between 2-3 columns in landscape +} + +/** + * Calculate grid item width based on column count + */ +export function getGridItemWidth(containerWidth: number, columns: number, gap: number = 16): number { + const totalGap = gap * (columns - 1); + return (containerWidth - totalGap) / columns; +} + +/** + * Get responsive padding + */ +export function getResponsivePadding(isLandscape: boolean, portraitPadding: number = 24): number { + return isLandscape ? portraitPadding * 0.67 : portraitPadding; // Reduce padding by 33% in landscape +} + +/** + * Get responsive font size + */ +export function getResponsiveFontSize(isLandscape: boolean, baseFontSize: number): number { + return isLandscape ? baseFontSize * 0.9 : baseFontSize; // Slightly smaller in landscape +} + +/** + * Get max keyboard height for landscape mode + */ +export function getMaxKeyboardHeight(screenHeight: number, isLandscape: boolean): number { + if (!isLandscape) return screenHeight; // No limit in portrait + return screenHeight * 0.4; // 40% max in landscape +} + +/** + * Get responsive tab bar height + */ +export function getTabBarHeight(isLandscape: boolean): number { + return isLandscape ? 50 : 60; +} + +/** + * Get responsive button size + */ +export function getButtonSize(isLandscape: boolean, portraitSize: number = 44): number { + return isLandscape ? portraitSize * 0.82 : portraitSize; // ~36px in landscape vs 44px portrait +} diff --git a/plugins/withIOSNetworkSecurity.js b/plugins/withIOSNetworkSecurity.js index c524190..be91a4d 100644 --- a/plugins/withIOSNetworkSecurity.js +++ b/plugins/withIOSNetworkSecurity.js @@ -9,6 +9,44 @@ const withIOSNetworkSecurity = (config) => { NSAllowsLocalNetworking: true, NSAllowsArbitraryLoadsInWebContent: true, NSAllowsArbitraryLoadsForMedia: true, + NSExceptionDomains: { + "localhost": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + "127.0.0.1": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + "0.0.0.0": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + "192.168.0.0": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + "10.0.0.0": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + "172.16.0.0": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + "100.64.0.0": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + "169.254.0.0": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + "fd00::": { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + }, }; existingPlist.NSLocalNetworkUsageDescription = From 0fe9e2b987dc085c1be52934232da07c3f6033fb Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 23 Nov 2025 23:35:07 -0600 Subject: [PATCH 02/27] fix: Improve UI issues --- app/Tabs/Hosts/Navigation/Host.tsx | 4 +- app/Tabs/Sessions/BottomToolbar.tsx | 14 +- app/Tabs/Sessions/CustomKeyboard.tsx | 4 +- app/Tabs/Sessions/FileManager.tsx | 17 +- app/Tabs/Sessions/FileManager/FileItem.tsx | 22 +- app/Tabs/Sessions/FileManager/FileList.tsx | 18 +- .../FileManager/FileManagerHeader.tsx | 113 +++++++--- .../FileManager/FileManagerToolbar.tsx | 97 ++++++-- app/Tabs/Sessions/Navigation/TabBar.tsx | 68 +++--- app/Tabs/Sessions/ServerStats.tsx | 11 +- app/Tabs/Sessions/Sessions.tsx | 210 ++++++++++-------- app/Tabs/Sessions/Terminal.tsx | 127 ++++++----- app/_layout.tsx | 11 +- app/main-axios.ts | 2 +- 14 files changed, 476 insertions(+), 242 deletions(-) diff --git a/app/Tabs/Hosts/Navigation/Host.tsx b/app/Tabs/Hosts/Navigation/Host.tsx index 6ad217a..44a1fe9 100644 --- a/app/Tabs/Hosts/Navigation/Host.tsx +++ b/app/Tabs/Hosts/Navigation/Host.tsx @@ -366,7 +366,7 @@ function Host({ host, status, isLast = false }: HostProps) { className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + View Server Stats @@ -386,7 +386,7 @@ function Host({ host, status, isLast = false }: HostProps) { className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + File Manager diff --git a/app/Tabs/Sessions/BottomToolbar.tsx b/app/Tabs/Sessions/BottomToolbar.tsx index 0e6c0e4..a4a00c0 100644 --- a/app/Tabs/Sessions/BottomToolbar.tsx +++ b/app/Tabs/Sessions/BottomToolbar.tsx @@ -28,6 +28,9 @@ export default function BottomToolbar({ if (!isVisible) return null; + // Constrain keyboard height to safe values + const safeKeyboardHeight = Math.max(200, Math.min(keyboardHeight, 500)); + const tabs: { id: ToolbarMode; label: string; icon: string }[] = [ { id: "keyboard", label: "Keyboard", icon: "⌨️" }, { id: "snippets", label: "Snippets", icon: "📋" }, @@ -62,12 +65,12 @@ export default function BottomToolbar({ {/* Content Area */} - + {mode === "keyboard" && ( )} @@ -76,7 +79,7 @@ export default function BottomToolbar({ )} @@ -84,7 +87,7 @@ export default function BottomToolbar({ )} @@ -98,6 +101,7 @@ const styles = StyleSheet.create({ backgroundColor: "#0e0e10", borderTopWidth: 1.5, borderTopColor: "#303032", + maxHeight: 550, }, tabBar: { flexDirection: "row", @@ -131,6 +135,6 @@ const styles = StyleSheet.create({ color: "#9333ea", }, content: { - flex: 1, + overflow: "hidden", }, }); diff --git a/app/Tabs/Sessions/CustomKeyboard.tsx b/app/Tabs/Sessions/CustomKeyboard.tsx index 7f408b0..36fff0f 100644 --- a/app/Tabs/Sessions/CustomKeyboard.tsx +++ b/app/Tabs/Sessions/CustomKeyboard.tsx @@ -124,8 +124,10 @@ export default function CustomKeyboard({ return baseStyle; }; + const safeKeyboardHeight = Math.max(200, Math.min(keyboardHeight, 500)); + return ( - + ( ({ host, sessionId }, ref) => { + const insets = useSafeAreaInsets(); + const { width, isLandscape } = useOrientation(); const [currentPath, setCurrentPath] = useState("/"); const [files, setFiles] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -409,8 +414,11 @@ export const FileManager = forwardRef( ); } + const padding = getResponsivePadding(isLandscape); + const tabBarHeight = getTabBarHeight(isLandscape); + return ( - + ( onCreateFile={handleCreateFile} onMenuPress={() => setSelectionMode(true)} isLoading={isLoading} + isLandscape={isLandscape} /> ( selectionMode={selectionMode} isLoading={isLoading} onRefresh={() => loadDirectory(currentPath)} + isLandscape={isLandscape} + width={width} + toolbarHeight={tabBarHeight + insets.bottom} /> ( onCancelSelection={handleCancelSelection} clipboardCount={clipboard.files.length} clipboardOperation={clipboard.operation} + isLandscape={isLandscape} + bottomInset={insets.bottom} + tabBarHeight={tabBarHeight} /> {/* Context Menu */} diff --git a/app/Tabs/Sessions/FileManager/FileItem.tsx b/app/Tabs/Sessions/FileManager/FileItem.tsx index ea30850..7d2e9e8 100644 --- a/app/Tabs/Sessions/FileManager/FileItem.tsx +++ b/app/Tabs/Sessions/FileManager/FileItem.tsx @@ -12,6 +12,8 @@ interface FileItemProps { onLongPress: () => void; onSelectToggle?: () => void; selectionMode?: boolean; + columnCount?: number; + useGrid?: boolean; } export function FileItem({ @@ -24,15 +26,29 @@ export function FileItem({ onLongPress, onSelectToggle, selectionMode = false, + columnCount = 1, + useGrid = false, }: FileItemProps) { const iconColor = getFileIconColor(name, type); const IconComponent = type === "directory" ? Folder : type === "link" ? Link : File; + const itemWidth = useGrid ? `${100 / columnCount - 0.5}%` : "100%"; + return ( void; sortBy?: "name" | "size" | "modified"; sortOrder?: "asc" | "desc"; + isLandscape: boolean; + width: number; + toolbarHeight: number; } export function FileList({ @@ -35,8 +39,13 @@ export function FileList({ onRefresh, sortBy = "name", sortOrder = "asc", + isLandscape, + width, + toolbarHeight, }: FileListProps) { const sortedFiles = sortFiles(files, sortBy, sortOrder); + const columnCount = getColumnCount(width, isLandscape, 300); + const useGrid = isLandscape && columnCount > 1; if (!isLoading && files.length === 0) { return ( @@ -59,6 +68,11 @@ export function FileList({ return ( onFileLongPress(file)} onSelectToggle={() => onSelectToggle(file.path)} selectionMode={selectionMode} + columnCount={columnCount} + useGrid={useGrid} /> ))} - {/* Add bottom padding for toolbar */} - ); } diff --git a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx index 2085c9f..c7936d8 100644 --- a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx +++ b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx @@ -1,4 +1,5 @@ import { View, Text, TouchableOpacity, ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ChevronLeft, RefreshCw, @@ -8,6 +9,7 @@ import { MoreVertical, } from "lucide-react-native"; import { breadcrumbsFromPath, getBreadcrumbLabel } from "./utils/fileUtils"; +import { getResponsivePadding, getResponsiveFontSize } from "@/app/utils/responsive"; interface FileManagerHeaderProps { currentPath: string; @@ -18,6 +20,7 @@ interface FileManagerHeaderProps { onUpload?: () => void; onMenuPress: () => void; isLoading: boolean; + isLandscape: boolean; } export function FileManagerHeader({ @@ -29,52 +32,70 @@ export function FileManagerHeader({ onUpload, onMenuPress, isLoading, + isLandscape, }: FileManagerHeaderProps) { + const insets = useSafeAreaInsets(); const breadcrumbs = breadcrumbsFromPath(currentPath); const isRoot = currentPath === "/"; + const padding = getResponsivePadding(isLandscape); + const iconSize = isLandscape ? 16 : 18; + const chevronSize = isLandscape ? 18 : 20; + const buttonPadding = isLandscape ? 6 : 8; return ( - + {/* Path breadcrumbs */} - + {!isRoot && ( { const parentPath = breadcrumbs[breadcrumbs.length - 2] || "/"; onNavigateToPath(parentPath); }} - className="mr-2 p-1" + style={{ marginRight: 8, padding: 4 }} activeOpacity={0.7} > - + )} {breadcrumbs.map((path, index) => ( - + {index > 0 && ( - / + / )} onNavigateToPath(path)} - className={`px-2 py-1 rounded ${ - index === breadcrumbs.length - 1 - ? "bg-dark-bg-button" - : "" - }`} + style={{ + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + backgroundColor: index === breadcrumbs.length - 1 ? "#27272a" : "transparent", + }} activeOpacity={0.7} > {getBreadcrumbLabel(path)} @@ -85,15 +106,22 @@ export function FileManagerHeader({ {/* Action buttons */} - + - + - + {onUpload && ( - + )} - + - + diff --git a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx index 20b9fba..690c6a8 100644 --- a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx +++ b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx @@ -1,5 +1,6 @@ import { View, Text, TouchableOpacity } from "react-native"; import { Copy, Scissors, Clipboard, Trash2, X } from "lucide-react-native"; +import { getResponsivePadding } from "@/app/utils/responsive"; interface FileManagerToolbarProps { selectionMode: boolean; @@ -11,6 +12,9 @@ interface FileManagerToolbarProps { onCancelSelection: () => void; clipboardCount?: number; clipboardOperation?: "copy" | "cut" | null; + isLandscape: boolean; + bottomInset: number; + tabBarHeight: number; } export function FileManagerToolbar({ @@ -23,30 +27,55 @@ export function FileManagerToolbar({ onCancelSelection, clipboardCount = 0, clipboardOperation = null, + isLandscape, + bottomInset, + tabBarHeight, }: FileManagerToolbarProps) { if (!selectionMode && clipboardCount === 0) { return null; } + const padding = getResponsivePadding(isLandscape); + const iconSize = isLandscape ? 18 : 20; + const buttonPadding = isLandscape ? 6 : 8; + return ( - + {selectionMode ? ( - + {/* Selection count */} - + {selectedCount} selected - + {/* Copy */} @@ -54,12 +83,18 @@ export function FileManagerToolbar({ {/* Cut */} @@ -67,12 +102,18 @@ export function FileManagerToolbar({ {/* Delete */} @@ -80,23 +121,30 @@ export function FileManagerToolbar({ {/* Cancel */} - + ) : ( - + {/* Clipboard info */} - + {clipboardOperation === "copy" ? ( - + ) : ( - + )} - + {clipboardCount} item{clipboardCount !== 1 ? "s" : ""}{" "} {clipboardOperation === "copy" ? "copied" : "cut"} @@ -105,11 +153,20 @@ export function FileManagerToolbar({ {/* Paste button */} - - Paste + + Paste )} diff --git a/app/Tabs/Sessions/Navigation/TabBar.tsx b/app/Tabs/Sessions/Navigation/TabBar.tsx index 3035844..fe16f4d 100644 --- a/app/Tabs/Sessions/Navigation/TabBar.tsx +++ b/app/Tabs/Sessions/Navigation/TabBar.tsx @@ -7,6 +7,7 @@ import { TextInput, Keyboard, } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { X, ArrowLeft, @@ -33,6 +34,7 @@ interface TabBarProps { onHideKeyboard?: () => void; onShowKeyboard?: () => void; keyboardIntentionallyHiddenRef: React.MutableRefObject; + activeSessionType?: "terminal" | "stats" | "filemanager"; } export default function TabBar({ @@ -47,14 +49,19 @@ export default function TabBar({ onHideKeyboard, onShowKeyboard, keyboardIntentionallyHiddenRef, + activeSessionType, }: TabBarProps) { const router = useRouter(); const { isKeyboardVisible } = useKeyboard(); const { isLandscape } = useOrientation(); + const insets = useSafeAreaInsets(); const tabBarHeight = getTabBarHeight(isLandscape); const buttonSize = getButtonSize(isLandscape); + // Add bottom padding for non-terminal sessions (when tab bar is at the bottom) + const needsBottomPadding = activeSessionType !== "terminal"; + const handleToggleSystemKeyboard = () => { if (keyboardIntentionallyHiddenRef.current) { onShowKeyboard?.(); @@ -77,8 +84,9 @@ export default function TabBar({ backgroundColor: "#0e0e10", borderTopWidth: isLandscape ? 1 : 1.5, borderTopColor: "#303032", - minHeight: tabBarHeight, - maxHeight: tabBarHeight, + minHeight: tabBarHeight + (needsBottomPadding ? insets.bottom : 0), + maxHeight: tabBarHeight + (needsBottomPadding ? insets.bottom : 0), + paddingBottom: needsBottomPadding ? insets.bottom : 0, }} focusable={false} > @@ -86,7 +94,7 @@ export default function TabBar({ style={{ flexDirection: "row", alignItems: "center", - height: "100%", + height: tabBarHeight, paddingHorizontal: 8, }} > @@ -197,7 +205,7 @@ export default function TabBar({ - {!isCustomKeyboardVisible && ( + {activeSessionType === "terminal" && !isCustomKeyboardVisible && ( )} - onToggleKeyboard?.()} - focusable={false} - className="items-center justify-center rounded-md" - activeOpacity={0.7} - style={{ - width: buttonSize, - height: buttonSize, - borderWidth: isLandscape ? 1.5 : 2, - borderColor: "#303032", - backgroundColor: "#2a2a2a", - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - marginLeft: isLandscape ? 6 : 8, - }} - > - {isCustomKeyboardVisible ? ( - - ) : ( - - )} - + {activeSessionType === "terminal" && ( + onToggleKeyboard?.()} + focusable={false} + className="items-center justify-center rounded-md" + activeOpacity={0.7} + style={{ + width: buttonSize, + height: buttonSize, + borderWidth: isLandscape ? 1.5 : 2, + borderColor: "#303032", + backgroundColor: "#2a2a2a", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + marginLeft: isLandscape ? 6 : 8, + }} + > + {isCustomKeyboardVisible ? ( + + ) : ( + + )} + + )} ); diff --git a/app/Tabs/Sessions/ServerStats.tsx b/app/Tabs/Sessions/ServerStats.tsx index 25f4885..055d5a0 100644 --- a/app/Tabs/Sessions/ServerStats.tsx +++ b/app/Tabs/Sessions/ServerStats.tsx @@ -14,6 +14,7 @@ import { RefreshControl, TouchableOpacity, } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Cpu, MemoryStick, @@ -26,7 +27,7 @@ import { getServerMetricsById } from "../../main-axios"; import { showToast } from "../../utils/toast"; import type { ServerMetrics } from "../../../types/index"; import { useOrientation } from "@/app/utils/orientation"; -import { getResponsivePadding, getColumnCount } from "@/app/utils/responsive"; +import { getResponsivePadding, getColumnCount, getTabBarHeight } from "@/app/utils/responsive"; interface ServerStatsProps { hostConfig: { @@ -44,6 +45,7 @@ export type ServerStatsHandle = { export const ServerStats = forwardRef( ({ hostConfig, isVisible, title = "Server Stats", onClose }, ref) => { + const insets = useSafeAreaInsets(); const { width, isLandscape } = useOrientation(); const [metrics, setMetrics] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -53,6 +55,7 @@ export const ServerStats = forwardRef( const padding = getResponsivePadding(isLandscape); const columnCount = getColumnCount(width, isLandscape, 350); + const tabBarHeight = getTabBarHeight(isLandscape); const fetchMetrics = useCallback( async (showLoadingSpinner = true) => { @@ -180,9 +183,8 @@ export const ServerStats = forwardRef( {isLoading && !metrics ? ( @@ -257,6 +259,9 @@ export const ServerStats = forwardRef( style={{ flex: 1 }} contentContainerStyle={{ padding, + paddingLeft: Math.max(insets.left, padding), + paddingRight: Math.max(insets.right, padding), + paddingBottom: tabBarHeight + insets.bottom + 12, flexDirection: isLandscape && columnCount > 1 ? "row" : "column", flexWrap: "wrap", gap: 12, diff --git a/app/Tabs/Sessions/Sessions.tsx b/app/Tabs/Sessions/Sessions.tsx index b7fd8b2..1ccb6da 100644 --- a/app/Tabs/Sessions/Sessions.tsx +++ b/app/Tabs/Sessions/Sessions.tsx @@ -151,17 +151,20 @@ export default function Sessions() { React.useCallback(() => { if ( sessions.length > 0 && + activeSession?.type === "terminal" && !isCustomKeyboardVisible && !keyboardIntentionallyHiddenRef.current ) { - setTimeout(() => { + const timeoutId = setTimeout(() => { hiddenInputRef.current?.focus(); - }, 1000); + }, 500); + return () => clearTimeout(timeoutId); } return () => {}; }, [ sessions.length, + activeSession?.type, isCustomKeyboardVisible, keyboardIntentionallyHiddenRef, ]), @@ -172,16 +175,13 @@ export default function Sessions() { if (nextAppState === "active") { if ( sessions.length > 0 && + activeSession?.type === "terminal" && !isCustomKeyboardVisible && !keyboardIntentionallyHiddenRef.current ) { setTimeout(() => { - setKeyboardType("email-address"); - setTimeout(() => { - setKeyboardType("default"); - hiddenInputRef.current?.focus(); - }, 100); - }, 250); + hiddenInputRef.current?.focus(); + }, 300); } } }); @@ -189,7 +189,7 @@ export default function Sessions() { return () => { subscription.remove(); }; - }, [sessions.length, isCustomKeyboardVisible, activeSessionId]); + }, [sessions.length, activeSession?.type, isCustomKeyboardVisible]); useEffect(() => { if (Platform.OS === "android" && sessions.length > 0) { @@ -211,24 +211,25 @@ export default function Sessions() { } }, [sessions.length, isKeyboardVisible]); - useEffect(() => { - if ( - sessions.length > 0 && - !isKeyboardVisible && - !isCustomKeyboardVisible && - !keyboardIntentionallyHiddenRef.current - ) { - const timeoutId = setTimeout(() => { - hiddenInputRef.current?.focus(); - }, 3000); - return () => clearTimeout(timeoutId); - } - }, [ - isKeyboardVisible, - sessions.length, - isCustomKeyboardVisible, - keyboardIntentionallyHiddenRef, - ]); + // Remove the auto-focus after 3 seconds - it causes keyboard flickering + // useEffect(() => { + // if ( + // sessions.length > 0 && + // !isKeyboardVisible && + // !isCustomKeyboardVisible && + // !keyboardIntentionallyHiddenRef.current + // ) { + // const timeoutId = setTimeout(() => { + // hiddenInputRef.current?.focus(); + // }, 3000); + // return () => clearTimeout(timeoutId); + // } + // }, [ + // isKeyboardVisible, + // sessions.length, + // isCustomKeyboardVisible, + // keyboardIntentionallyHiddenRef, + // ]); useEffect(() => { const subscription = Dimensions.addEventListener("change", ({ window }) => { @@ -266,6 +267,7 @@ export default function Sessions() { React.useCallback(() => { if ( sessions.length > 0 && + activeSession?.type === "terminal" && !isCustomKeyboardVisible && !keyboardIntentionallyHiddenRef.current ) { @@ -280,26 +282,30 @@ export default function Sessions() { }, [ sessions.length, activeSessionId, + activeSession?.type, isCustomKeyboardVisible, keyboardIntentionallyHiddenRef, ]), ); const handleTabPress = (sessionId: string) => { + const session = sessions.find(s => s.id === sessionId); setKeyboardIntentionallyHidden(false); - hiddenInputRef.current?.focus(); - requestAnimationFrame(() => { - setActiveSession(sessionId); - setTimeout(() => hiddenInputRef.current?.focus(), 0); - }); + setActiveSession(sessionId); + setTimeout(() => { + if (session?.type === "terminal" && !isCustomKeyboardVisible) { + hiddenInputRef.current?.focus(); + } + }, 100); }; const handleTabClose = (sessionId: string) => { - hiddenInputRef.current?.focus(); - requestAnimationFrame(() => { - removeSession(sessionId); - setTimeout(() => hiddenInputRef.current?.focus(), 0); - }); + removeSession(sessionId); + setTimeout(() => { + if (activeSession?.type === "terminal" && !isCustomKeyboardVisible && sessions.length > 1) { + hiddenInputRef.current?.focus(); + } + }, 100); }; const handleAddSession = () => { @@ -339,7 +345,7 @@ export default function Sessions() { {sessions.map((session) => { @@ -494,7 +500,7 @@ export default function Sessions() { )} - {sessions.length > 0 && ( + {sessions.length > 0 && activeSession?.type === "terminal" && ( )} + {sessions.length > 0 && (activeSession?.type === "stats" || activeSession?.type === "filemanager") && isCustomKeyboardVisible && ( + + )} + 0 - ? currentKeyboardHeight + 50 - : 50, + bottom: activeSession?.type === "terminal" + ? keyboardIntentionallyHiddenRef.current + ? 66 + : isCustomKeyboardVisible + ? effectiveKeyboardHeight + 50 + : isKeyboardVisible && currentKeyboardHeight > 0 + ? currentKeyboardHeight + 50 + : 50 + : 0, left: 0, right: 0, height: 60, @@ -569,6 +591,7 @@ export default function Sessions() { onHideKeyboard={() => setKeyboardIntentionallyHidden(true)} onShowKeyboard={() => setKeyboardIntentionallyHidden(false)} keyboardIntentionallyHiddenRef={keyboardIntentionallyHiddenRef} + activeSessionType={activeSession?.type} /> @@ -629,58 +652,65 @@ export default function Sessions() { contextMenuHidden underlineColorAndroid="transparent" multiline - onChangeText={(text) => {}} + onChangeText={() => { + // Do nothing - we handle input in onKeyPress only + }} onKeyPress={({ nativeEvent }) => { const key = nativeEvent.key; const activeRef = activeSessionId ? terminalRefs.current[activeSessionId] : null; - if (activeRef && activeRef.current) { - let finalKey = key; - - if (activeModifiers.ctrl) { - switch (key.toLowerCase()) { - case "c": - finalKey = "\x03"; - break; - case "d": - finalKey = "\x04"; - break; - case "z": - finalKey = "\x1a"; - break; - case "l": - finalKey = "\x0c"; - break; - case "a": - finalKey = "\x01"; - break; - case "e": - finalKey = "\x05"; - break; - case "k": - finalKey = "\x0b"; - break; - case "u": - finalKey = "\x15"; - break; - case "w": - finalKey = "\x17"; - break; - default: + + if (!activeRef?.current) return; + + let finalKey = key; + + // Handle modifiers + if (activeModifiers.ctrl) { + switch (key.toLowerCase()) { + case "c": + finalKey = "\x03"; + break; + case "d": + finalKey = "\x04"; + break; + case "z": + finalKey = "\x1a"; + break; + case "l": + finalKey = "\x0c"; + break; + case "a": + finalKey = "\x01"; + break; + case "e": + finalKey = "\x05"; + break; + case "k": + finalKey = "\x0b"; + break; + case "u": + finalKey = "\x15"; + break; + case "w": + finalKey = "\x17"; + break; + default: + if (key.length === 1) { finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); - } - } else if (activeModifiers.alt) { - finalKey = `\x1b${key}`; + } } + } else if (activeModifiers.alt) { + finalKey = `\x1b${key}`; + } - if (key === "Enter") { - activeRef.current.sendInput("\r"); - } else if (key === "Backspace") { - activeRef.current.sendInput("\b"); - } else if (key.length === 1) { - activeRef.current.sendInput(finalKey); - } + // Send the appropriate key + if (key === "Enter") { + activeRef.current.sendInput("\r"); + } else if (key === "Backspace") { + activeRef.current.sendInput("\b"); + } else if (key.length === 1) { + activeRef.current.sendInput(finalKey); } }} onFocus={() => { diff --git a/app/Tabs/Sessions/Terminal.tsx b/app/Tabs/Sessions/Terminal.tsx index 7cd75d8..55a6ef4 100644 --- a/app/Tabs/Sessions/Terminal.tsx +++ b/app/Tabs/Sessions/Terminal.tsx @@ -20,6 +20,7 @@ import { getCurrentServerUrl, getCookie, logActivity, + saveCommandToHistory, } from "../../main-axios"; import { showToast } from "../../utils/toast"; import { useTerminalCustomization } from "../../contexts/TerminalCustomizationContext"; @@ -67,8 +68,8 @@ const TerminalComponent = forwardRef( const connectionTimeoutRef = useRef | null>( null, ); - const hiddenInputRef = useRef(null); - const isComposingRef = useRef(false); + const currentCommandRef = useRef(""); + const commandHistoryRef = useRef([]); useEffect(() => { const subscription = Dimensions.addEventListener( @@ -329,7 +330,40 @@ const TerminalComponent = forwardRef( let connectionTimeout = null; let shouldNotReconnect = false; let hasNotifiedFailure = false; - + + // Command history tracking + let currentCommand = ''; + let commandHistory = []; + + function trackInput(data) { + if (data === '\\r' || data === '\\n') { + // Enter key pressed - command executed + const cmd = currentCommand.trim(); + if (cmd && cmd.length > 0) { + // Notify React Native about the command + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'commandExecuted', + data: { command: cmd } + })); + } + } + currentCommand = ''; + } else if (data === '\\x7f' || data === '\\b') { + // Backspace + currentCommand = currentCommand.slice(0, -1); + } else if (data === '\\x03') { + // Ctrl+C - clear current command + currentCommand = ''; + } else if (data === '\\x15') { + // Ctrl+U - clear line + currentCommand = ''; + } else if (data.length === 1 && data.charCodeAt(0) >= 32) { + // Printable character + currentCommand += data; + } + } + function notifyConnectionState(state, data = {}) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ @@ -367,6 +401,7 @@ const TerminalComponent = forwardRef( window.nativeInput = function(data) { try { + trackInput(data); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data: data })); } else { @@ -396,6 +431,7 @@ const TerminalComponent = forwardRef( hiddenInput.addEventListener('compositionend', function(e) { isComposing = false; if (e.data && ws && ws.readyState === WebSocket.OPEN) { + trackInput(e.data); ws.send(JSON.stringify({ type: 'input', data: e.data })); } hiddenInput.value = ''; @@ -416,6 +452,7 @@ const TerminalComponent = forwardRef( lastInputValue = value; if (value && ws && ws.readyState === WebSocket.OPEN) { + trackInput(value); ws.send(JSON.stringify({ type: 'input', data: value })); } hiddenInput.value = ''; @@ -428,11 +465,13 @@ const TerminalComponent = forwardRef( if (e.key === 'Backspace') { e.preventDefault(); if (ws && ws.readyState === WebSocket.OPEN) { + trackInput('\\x7f'); ws.send(JSON.stringify({ type: 'input', data: '\\x7f' })); } } else if (e.key === 'Enter') { e.preventDefault(); if (ws && ws.readyState === WebSocket.OPEN) { + trackInput('\\r'); ws.send(JSON.stringify({ type: 'input', data: '\\r' })); } } else if (e.key === 'Tab') { @@ -692,10 +731,28 @@ const TerminalComponent = forwardRef( `${message.data.hostName}: ${message.data.message}`, ); break; + + case "commandExecuted": + // Save command to history + if (message.data.command && hostConfig.id) { + const cmd = message.data.command; + currentCommandRef.current = ""; + + // Don't save duplicate commands or very short commands + if (cmd.length > 1 && !commandHistoryRef.current.includes(cmd)) { + commandHistoryRef.current = [cmd, ...commandHistoryRef.current]; + + // Save to backend asynchronously + saveCommandToHistory(hostConfig.id, cmd).catch((error) => { + console.error("Failed to save command to history:", error); + }); + } + } + break; } } catch (error) {} }, - [handleConnectionFailure, onClose], + [handleConnectionFailure, onClose, hostConfig.id], ); useImperativeHandle( @@ -739,13 +796,14 @@ const TerminalComponent = forwardRef( } }, [hostConfig.id, currentHostId]); - useEffect(() => { - if (isVisible && isConnected && !showConnectingOverlay) { - setTimeout(() => { - focusTerminal(); - }, 300); - } - }, [isVisible, isConnected, showConnectingOverlay, focusTerminal]); + // Focus handling removed - now managed by Sessions.tsx + // useEffect(() => { + // if (isVisible && isConnected && !showConnectingOverlay) { + // setTimeout(() => { + // focusTerminal(); + // }, 300); + // } + // }, [isVisible, isConnected, showConnectingOverlay, focusTerminal]); useEffect(() => { return () => { @@ -755,24 +813,9 @@ const TerminalComponent = forwardRef( }; }, []); - const handleNativeTextChange = useCallback((text: string) => { - if (!isComposingRef.current && text && webViewRef.current) { - try { - const escaped = JSON.stringify(text); - webViewRef.current.injectJavaScript( - `if (window.hiddenInput) { window.hiddenInput.value = ${escaped}; window.hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); } true;`, - ); - } catch (e) { - console.error("Failed to send input:", e); - } - } - }, []); - const focusTerminal = useCallback(() => { - if (hiddenInputRef.current && isConnected && !showConnectingOverlay) { - hiddenInputRef.current.focus(); - } - }, [isConnected, showConnectingOverlay]); + // Focus is now handled by Sessions.tsx + }, []); return ( ( }} > - {/* Hidden TextInput for better keyboard support on Android */} - { - Keyboard.dismiss(); - setTimeout(() => { - if (hiddenInputRef.current) { - hiddenInputRef.current.focus(); - } - }, 50); - }} - /> + {/* Note: Hidden TextInput removed - keyboard handled by Sessions.tsx */} Initializing... + { + setShowLoginForm(false); + setShowServerManager(true); + }} + className="mt-6 px-6 py-3 bg-[#1a1a1a] border border-[#303032] rounded-lg" + > + Cancel + ); } diff --git a/app/main-axios.ts b/app/main-axios.ts index aec0217..1daca79 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -190,7 +190,7 @@ function createApiInstance( config.headers["User-Agent"] = `Termix-Mobile/${platform.OS}`; } - return config;\ + return config; }); instance.interceptors.response.use( From 623578ba1e3a9b3e0883e53a04bf3d50e61cec34 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 30 Nov 2025 23:27:14 -0600 Subject: [PATCH 03/27] feat: Greatly improve UI consistency, improve UI for all components (local squash) --- Terminal.tsx | 1583 ----------------- app/Authentication/LoginForm.tsx | 4 +- app/Authentication/UpdateRequired.tsx | 2 +- app/Tabs/Hosts/Hosts.tsx | 2 +- app/Tabs/Hosts/Navigation/Host.tsx | 1 + app/Tabs/Sessions/BottomToolbar.tsx | 104 +- app/Tabs/Sessions/CommandHistoryBar.tsx | 225 +-- app/Tabs/Sessions/CustomKeyboard.tsx | 101 +- app/Tabs/Sessions/FileManager.tsx | 302 +++- app/Tabs/Sessions/FileManager/FileItem.tsx | 10 +- app/Tabs/Sessions/FileManager/FileList.tsx | 8 +- .../FileManager/FileManagerHeader.tsx | 61 +- .../FileManager/FileManagerToolbar.tsx | 46 +- app/Tabs/Sessions/FileManager/FileViewer.tsx | 8 +- .../Sessions/FileManager/utils/fileUtils.ts | 14 +- app/Tabs/Sessions/KeyboardBar.tsx | 66 +- app/Tabs/Sessions/KeyboardKey.tsx | 118 +- app/Tabs/Sessions/Navigation/TabBar.tsx | 87 +- app/Tabs/Sessions/ServerStats.tsx | 238 ++- app/Tabs/Sessions/Sessions.tsx | 84 +- app/Tabs/Sessions/SnippetsBar.tsx | 198 +-- app/Tabs/Sessions/Terminal.tsx | 138 +- app/Tabs/Settings/KeyboardCustomization.tsx | 9 +- app/_layout.tsx | 3 + tailwind.config.js | 5 + 25 files changed, 783 insertions(+), 2634 deletions(-) delete mode 100644 Terminal.tsx diff --git a/Terminal.tsx b/Terminal.tsx deleted file mode 100644 index e0aec7f..0000000 --- a/Terminal.tsx +++ /dev/null @@ -1,1583 +0,0 @@ -import { - useEffect, - useRef, - useState, - useImperativeHandle, - forwardRef, - useCallback, -} from "react"; -import { useXTerm } from "react-xtermjs"; -import { FitAddon } from "@xterm/addon-fit"; -import { ClipboardAddon } from "@xterm/addon-clipboard"; -import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebLinksAddon } from "@xterm/addon-web-links"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; -import { - 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, -} from "@/constants/terminal-themes"; -import type { TerminalConfig } from "@/types"; -import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; -import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory"; -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"; - -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; -} - -interface TerminalHandle { - 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; -} - -export const Terminal = forwardRef( - function SSHTerminal( - { - hostConfig, - isVisible, - splitScreen = false, - onClose, - initialPath, - executeCommand, - }, - ref, - ) { - if ( - typeof window !== "undefined" && - !(window as { testJWT?: () => string | null }).testJWT - ) { - (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 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 getCurrentCommandRef = useRef(getCurrentCommand); - const updateCurrentCommandRef = useRef(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 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") !== "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, - ) { - 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); - } 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 }), - ); - } - - setTimeout(() => { - terminal.focus(); - }, 100); - }, - [terminal], - ); - - useEffect(() => { - commandHistoryContext.setOnSelectCommand(handleSelectCommand); - }, [handleSelectCommand]); - - const handleAutocompleteSelect = useCallback( - (selectedCommand: string) => { - if (!webSocketRef.current) return; - - const currentCmd = currentAutocompleteCommand.current; - const completion = selectedCommand.substring(currentCmd.length); - - for (const char of completion) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: char }), - ); - } - - 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 = { - ...DEFAULT_TERMINAL_CONFIG, - ...hostConfig.terminalConfig, - }; - - const themeColors = - TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors; - - 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(); - 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); - - const handleMacKeyboard = (e: KeyboardEvent) => { - const isMacOS = - navigator.platform.toUpperCase().indexOf("MAC") >= 0 || - navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; - - 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; - } - - 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 (!isMacOS) return; - - if (e.altKey && !e.metaKey && !e.ctrlKey) { - const keyMappings: { [key: string]: string } = { - "7": "|", - "2": "€", - "8": "[", - "9": "]", - l: "@", - L: "@", - Digit7: "|", - Digit2: "€", - Digit8: "[", - Digit9: "]", - KeyL: "@", - }; - - const char = keyMappings[e.key] || keyMappings[e.code]; - if (char) { - e.preventDefault(); - e.stopPropagation(); - - if (webSocketRef.current?.readyState === 1) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: char }), - ); - } - return false; - } - } - }; - - element?.addEventListener("keydown", handleMacKeyboard, true); - - const resizeObserver = new ResizeObserver(() => { - if (resizeTimeout.current) clearTimeout(resizeTimeout.current); - resizeTimeout.current = setTimeout(() => { - if (!isVisibleRef.current || !isReady) return; - performFit(); - }, 50); - }); - - resizeObserver.observe(xtermRef.current); - - setVisible(true); - - 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]); - - useEffect(() => { - if (!terminal) return; - - const handleCustomKey = (e: KeyboardEvent): boolean => { - if (e.type !== "keydown") { - return true; - } - - if (showAutocompleteRef.current) { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - setShowAutocomplete(false); - setAutocompleteSuggestions([]); - currentAutocompleteCommand.current = ""; - return false; - } - - 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 }), - ); - } - } - - updateCurrentCommandRef.current(selectedCommand); - - setShowAutocomplete(false); - setAutocompleteSuggestions([]); - currentAutocompleteCommand.current = ""; - - return false; - } - - 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") !== "false"; - - if (!autocompleteEnabled) { - if (webSocketRef.current?.readyState === 1) { - webSocketRef.current.send( - JSON.stringify({ type: "input", data: "\t" }), - ); - } - return false; - } - - 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); - - 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"); -style.innerHTML = ` -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap'); - -@font-face { - font-family: 'Caskaydia Cove Nerd Font Mono'; - src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype'); - font-weight: normal; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Caskaydia Cove Nerd Font Mono'; - src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype'); - font-weight: bold; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Caskaydia Cove Nerd Font Mono'; - src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype'); - font-weight: normal; - font-style: italic; - font-display: swap; -} - -@font-face { - font-family: 'Caskaydia Cove Nerd Font Mono'; - src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype'); - font-weight: bold; - font-style: italic; - font-display: swap; -} - -.xterm .xterm-viewport::-webkit-scrollbar { - width: 8px; - background: transparent; -} -.xterm .xterm-viewport::-webkit-scrollbar-thumb { - background: rgba(180,180,180,0.7); - border-radius: 4px; -} -.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { - background: rgba(120,120,120,0.9); -} -.xterm .xterm-viewport { - scrollbar-width: thin; - scrollbar-color: rgba(180,180,180,0.7) transparent; -} - -.xterm { - font-feature-settings: "liga" 1, "calt" 1; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.xterm .xterm-screen { - font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important; - font-variant-ligatures: contextual; -} - -.xterm .xterm-screen .xterm-char { - font-feature-settings: "liga" 1, "calt" 1; -} -`; -document.head.appendChild(style); diff --git a/app/Authentication/LoginForm.tsx b/app/Authentication/LoginForm.tsx index 6bc6988..fa6ae18 100644 --- a/app/Authentication/LoginForm.tsx +++ b/app/Authentication/LoginForm.tsx @@ -364,7 +364,7 @@ export default function LoginForm() { if (!source.uri) { return ( - + Loading server configuration... ); @@ -437,7 +437,7 @@ export default function LoginForm() { alignItems: "center", }} > - + )} /> diff --git a/app/Authentication/UpdateRequired.tsx b/app/Authentication/UpdateRequired.tsx index ca5f454..eac1f30 100644 --- a/app/Authentication/UpdateRequired.tsx +++ b/app/Authentication/UpdateRequired.tsx @@ -65,7 +65,7 @@ export default function UpdateRequired() { className="flex-1 bg-[#18181b] justify-center items-center" style={{ paddingTop: insets.top }} > - + Loading version information... diff --git a/app/Tabs/Hosts/Hosts.tsx b/app/Tabs/Hosts/Hosts.tsx index 3d6e43b..0246738 100644 --- a/app/Tabs/Hosts/Hosts.tsx +++ b/app/Tabs/Hosts/Hosts.tsx @@ -171,7 +171,7 @@ export default function Hosts() { className="flex-1 bg-dark-bg px-6 justify-center items-center" style={{ paddingTop: insets.top + 24 }} > - + Loading hosts... ); diff --git a/app/Tabs/Hosts/Navigation/Host.tsx b/app/Tabs/Hosts/Navigation/Host.tsx index 44a1fe9..f728a59 100644 --- a/app/Tabs/Hosts/Navigation/Host.tsx +++ b/app/Tabs/Hosts/Navigation/Host.tsx @@ -309,6 +309,7 @@ function Host({ host, status, isLast = false }: HostProps) { transparent={true} animationType="fade" onRequestClose={handleCloseContextMenu} + supportedOrientations={['portrait', 'landscape']} > diff --git a/app/Tabs/Sessions/BottomToolbar.tsx b/app/Tabs/Sessions/BottomToolbar.tsx index a4a00c0..a8aa6d1 100644 --- a/app/Tabs/Sessions/BottomToolbar.tsx +++ b/app/Tabs/Sessions/BottomToolbar.tsx @@ -1,10 +1,11 @@ import React, { useState } from "react"; -import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; +import { View, Text, TouchableOpacity } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { TerminalHandle } from "./Terminal"; import CustomKeyboard from "./CustomKeyboard"; import SnippetsBar from "./SnippetsBar"; import CommandHistoryBar from "./CommandHistoryBar"; +import { BORDERS, BORDER_COLORS, BACKGROUNDS } from "@/app/constants/designTokens"; type ToolbarMode = "keyboard" | "snippets" | "history"; @@ -31,41 +32,67 @@ export default function BottomToolbar({ // Constrain keyboard height to safe values const safeKeyboardHeight = Math.max(200, Math.min(keyboardHeight, 500)); - const tabs: { id: ToolbarMode; label: string; icon: string }[] = [ - { id: "keyboard", label: "Keyboard", icon: "⌨️" }, - { id: "snippets", label: "Snippets", icon: "📋" }, - { id: "history", label: "History", icon: "🕒" }, + const tabs: { id: ToolbarMode; label: string }[] = [ + { id: "keyboard", label: "KEYBOARD" }, + { id: "snippets", label: "SNIPPETS" }, + { id: "history", label: "HISTORY" }, ]; + // Total height includes tab bar + content area (padding handled separately) + const TAB_BAR_HEIGHT = 36; + return ( {/* Tab Bar */} - - {tabs.map((tab) => ( + + {tabs.map((tab, index) => ( setMode(tab.id)} + style={{ + borderRightWidth: index !== tabs.length - 1 ? BORDERS.STANDARD : 0, + borderRightColor: BORDER_COLORS.SECONDARY, + }} > - {tab.icon} {tab.label} + {mode === tab.id && ( + + )} ))} {/* Content Area */} - + {mode === "keyboard" && ( ); } - -const styles = StyleSheet.create({ - container: { - backgroundColor: "#0e0e10", - borderTopWidth: 1.5, - borderTopColor: "#303032", - maxHeight: 550, - }, - tabBar: { - flexDirection: "row", - backgroundColor: "#18181b", - borderBottomWidth: 1, - borderBottomColor: "#303032", - }, - tab: { - flex: 1, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 12, - gap: 6, - backgroundColor: "#18181b", - }, - tabActive: { - backgroundColor: "#0e0e10", - borderBottomWidth: 2, - borderBottomColor: "#9333ea", - }, - tabIcon: { - fontSize: 16, - }, - tabLabel: { - fontSize: 13, - fontWeight: "600", - color: "#888", - }, - tabLabelActive: { - color: "#9333ea", - }, - content: { - overflow: "hidden", - }, -}); diff --git a/app/Tabs/Sessions/CommandHistoryBar.tsx b/app/Tabs/Sessions/CommandHistoryBar.tsx index 72e71a2..e54d9d2 100644 --- a/app/Tabs/Sessions/CommandHistoryBar.tsx +++ b/app/Tabs/Sessions/CommandHistoryBar.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from "react"; import { View, Text, - StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, @@ -15,6 +14,7 @@ import { clearCommandHistory, } from "@/app/main-axios"; import { showToast } from "@/app/utils/toast"; +import { BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; interface CommandHistoryItem { id: number; @@ -57,6 +57,12 @@ export default function CommandHistoryBar({ const loadHistory = async () => { try { setLoading(true); + // Don't load if no currentHostId + if (!currentHostId) { + setHistory([]); + setLoading(false); + return; + } const historyData = await getCommandHistory(); // Sort by timestamp descending (most recent first) @@ -67,7 +73,7 @@ export default function CommandHistoryBar({ setHistory(sortedHistory); } catch (error) { - showToast("Failed to load command history", "error"); + showToast.error("Failed to load command history"); } finally { setLoading(false); } @@ -95,7 +101,7 @@ export default function CommandHistoryBar({ const executeCommand = (command: string) => { if (terminalRef.current) { terminalRef.current.sendInput(command + "\n"); - showToast("Command executed", "success"); + showToast.success("Command executed"); } }; @@ -103,9 +109,9 @@ export default function CommandHistoryBar({ try { await deleteCommandFromHistory(commandId); setHistory((prev) => prev.filter((item) => item.id !== commandId)); - showToast("Command deleted", "success"); + showToast.success("Command deleted"); } catch (error) { - showToast("Failed to delete command", "error"); + showToast.error("Failed to delete command"); } }; @@ -113,9 +119,9 @@ export default function CommandHistoryBar({ try { await clearCommandHistory(); setHistory([]); - showToast("History cleared", "success"); + showToast.success("History cleared"); } catch (error) { - showToast("Failed to clear history", "error"); + showToast.error("Failed to clear history"); } }; @@ -139,31 +145,48 @@ export default function CommandHistoryBar({ if (loading) { return ( - - + + ); } return ( - - - Command History - - - + + + Command History + + + {history.length > 0 && ( - - 🗑 + + 🗑 )} - + {filteredHistory.map((item) => ( - + executeCommand(item.command)} > - + {item.command} - - {item.hostName} - + + {item.hostName} + {formatTimestamp(item.timestamp)} deleteCommand(item.id)} > - × + × ))} {filteredHistory.length === 0 && !searchQuery && ( - - No command history yet - + + No command history yet + Commands you run will appear here )} {filteredHistory.length === 0 && searchQuery && ( - - No matching commands - + + No matching commands + Try a different search term @@ -224,123 +259,3 @@ export default function CommandHistoryBar({ ); } - -const styles = StyleSheet.create({ - container: { - backgroundColor: "#0e0e10", - borderTopWidth: 1.5, - borderTopColor: "#303032", - }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingHorizontal: 12, - paddingVertical: 8, - borderBottomWidth: 1, - borderBottomColor: "#303032", - }, - headerText: { - fontSize: 14, - fontWeight: "600", - color: "#e5e5e7", - }, - headerActions: { - flexDirection: "row", - gap: 8, - }, - iconButton: { - padding: 4, - }, - refreshText: { - fontSize: 18, - color: "#9333ea", - }, - clearText: { - fontSize: 16, - color: "#ef4444", - }, - searchContainer: { - paddingHorizontal: 12, - paddingVertical: 8, - borderBottomWidth: 1, - borderBottomColor: "#303032", - }, - searchInput: { - backgroundColor: "#18181b", - color: "#e5e5e7", - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 6, - borderWidth: 1, - borderColor: "#303032", - fontSize: 13, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 8, - paddingVertical: 8, - }, - historyItem: { - flexDirection: "row", - backgroundColor: "#18181b", - borderRadius: 6, - marginBottom: 6, - borderWidth: 1, - borderColor: "#303032", - overflow: "hidden", - }, - commandTouchable: { - flex: 1, - paddingHorizontal: 12, - paddingVertical: 10, - }, - commandText: { - fontSize: 13, - color: "#e5e5e7", - fontWeight: "500", - fontFamily: "monospace", - marginBottom: 4, - }, - metaRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - hostText: { - fontSize: 11, - color: "#9333ea", - fontWeight: "600", - }, - timestampText: { - fontSize: 11, - color: "#666", - }, - deleteButton: { - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 12, - backgroundColor: "#1a1a1d", - }, - deleteText: { - fontSize: 24, - color: "#ef4444", - fontWeight: "300", - }, - emptyContainer: { - paddingVertical: 32, - alignItems: "center", - }, - emptyText: { - fontSize: 14, - color: "#888", - fontWeight: "600", - }, - emptySubtext: { - fontSize: 12, - color: "#666", - marginTop: 4, - }, -}); diff --git a/app/Tabs/Sessions/CustomKeyboard.tsx b/app/Tabs/Sessions/CustomKeyboard.tsx index 36fff0f..59c4eea 100644 --- a/app/Tabs/Sessions/CustomKeyboard.tsx +++ b/app/Tabs/Sessions/CustomKeyboard.tsx @@ -1,9 +1,10 @@ import React from "react"; -import { View, StyleSheet, ScrollView, Clipboard, Text } from "react-native"; +import { View, ScrollView, Clipboard, Text } from "react-native"; import { TerminalHandle } from "./Terminal"; import KeyboardKey from "./KeyboardKey"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; import { KeyConfig } from "@/types/keyboard"; +import { BORDER_COLORS, SPACING } from "@/app/constants/designTokens"; interface CustomKeyboardProps { terminalRef: React.RefObject; @@ -127,27 +128,28 @@ export default function CustomKeyboard({ const safeKeyboardHeight = Math.max(200, Math.min(keyboardHeight, 500)); return ( - + {visibleRows.map((row, rowIndex) => ( {row.label && ( - - {row.label} + + + {row.label} + )} {row.keys.map((key, keyIndex) => ( )} ))} {config.settings.showHints && !isKeyboardIntentionallyHidden && ( - - Customize in Settings + + + Customize in Settings + )} ); } - -const styles = StyleSheet.create({ - keyboard: { - backgroundColor: "#0e0e10", - borderTopWidth: 1.5, - borderTopColor: "#303032", - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 8, - paddingVertical: 8, - flexGrow: 1, - }, - rowLabelContainer: { - marginBottom: 4, - marginTop: 4, - }, - rowLabel: { - fontSize: 11, - color: "#888", - fontWeight: "600", - textTransform: "uppercase", - letterSpacing: 0.5, - }, - keyRow: { - flexDirection: "row", - alignItems: "center", - marginBottom: 0, - flexWrap: "wrap", - }, - numberRow: { - flexDirection: "row", - alignItems: "center", - marginBottom: 0, - flexWrap: "nowrap", - }, - compactRow: { - marginBottom: -2, - }, - separator: { - height: 1, - backgroundColor: "#404040", - marginVertical: 8, - marginHorizontal: 0, - }, - compactSeparator: { - marginVertical: 4, - }, - hintContainer: { - paddingHorizontal: 8, - paddingTop: 8, - paddingBottom: 4, - alignItems: "center", - }, - hintText: { - fontSize: 10, - color: "#666", - fontStyle: "italic", - }, -}); diff --git a/app/Tabs/Sessions/FileManager.tsx b/app/Tabs/Sessions/FileManager.tsx index 9421693..4fd17d1 100644 --- a/app/Tabs/Sessions/FileManager.tsx +++ b/app/Tabs/Sessions/FileManager.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from "react"; -import { View, Alert, TextInput, Modal, Text, TouchableOpacity, ActivityIndicator } from "react-native"; +import { View, Alert, TextInput, Modal, Text, TouchableOpacity, ActivityIndicator, KeyboardAvoidingView, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SSHHost } from "@/types"; import { useOrientation } from "@/app/utils/orientation"; import { getResponsivePadding, getTabBarHeight } from "@/app/utils/responsive"; +import { BORDERS, BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; import { connectSSH, listSSHFiles, @@ -17,6 +18,7 @@ import { moveSSHItem, verifySSHTOTP, keepSSHAlive, + identifySSHSymlink, } from "@/app/main-axios"; import { FileList } from "./FileManager/FileList"; import { FileManagerHeader } from "./FileManager/FileManagerHeader"; @@ -29,6 +31,7 @@ import { showToast } from "@/app/utils/toast"; interface FileManagerProps { host: SSHHost; sessionId: string; + isVisible: boolean; } interface FileItem { @@ -45,7 +48,7 @@ export interface FileManagerHandle { } export const FileManager = forwardRef( - ({ host, sessionId }, ref) => { + ({ host, sessionId, isVisible }, ref) => { const insets = useSafeAreaInsets(); const { width, isLandscape } = useOrientation(); const [currentPath, setCurrentPath] = useState("/"); @@ -121,7 +124,7 @@ export const FileManager = forwardRef( // Load initial directory await loadDirectory(host.defaultPath || "/"); } catch (error: any) { - showToast(error.message || "Failed to connect to SSH", "error"); + showToast.error(error.message || "Failed to connect to SSH"); } finally { setIsLoading(false); } @@ -143,7 +146,7 @@ export const FileManager = forwardRef( // Load initial directory await loadDirectory(host.defaultPath || "/"); } catch (error: any) { - showToast(error.message || "Invalid TOTP code", "error"); + showToast.error(error.message || "Invalid TOTP code"); } }; @@ -156,20 +159,49 @@ export const FileManager = forwardRef( setFiles(response.files || []); setCurrentPath(response.path || path); } catch (error: any) { - showToast(error.message || "Failed to load directory", "error"); + showToast.error(error.message || "Failed to load directory"); } finally { setIsLoading(false); } }, [sessionId]); // File operations - const handleFilePress = (file: FileItem) => { + const handleFilePress = async (file: FileItem) => { + // Handle symlinks by resolving target first + if (file.type === "link") { + try { + setIsLoading(true); + const symlinkInfo = await identifySSHSymlink(sessionId!, file.path); + + if (symlinkInfo.type === "directory") { + // Navigate to target directory + await loadDirectory(symlinkInfo.target); + } else if (isTextFile(symlinkInfo.target)) { + // View target file + const targetFile: FileItem = { + name: file.name, + path: symlinkInfo.target, + type: "file", + }; + await handleViewFile(targetFile); + } else { + showToast.info("File type not supported for viewing"); + } + } catch (error: any) { + showToast.error(error.message || "Failed to follow symlink"); + } finally { + setIsLoading(false); + } + return; + } + + // Handle regular files and directories if (file.type === "directory") { loadDirectory(file.path); } else if (isTextFile(file.name)) { handleViewFile(file); } else { - showToast("File type not supported for viewing", "info"); + showToast.info("File type not supported for viewing"); } }; @@ -183,7 +215,7 @@ export const FileManager = forwardRef( const response = await readSSHFile(sessionId!, file.path); setFileViewer({ visible: true, file, content: response.content }); } catch (error: any) { - showToast(error.message || "Failed to read file", "error"); + showToast.error(error.message || "Failed to read file"); } finally { setIsLoading(false); } @@ -194,7 +226,7 @@ export const FileManager = forwardRef( try { await writeSSHFile(sessionId!, fileViewer.file.path, content, host.id); - showToast("File saved successfully", "success"); + showToast.success("File saved successfully"); await loadDirectory(currentPath); } catch (error: any) { throw new Error(error.message || "Failed to save file"); @@ -218,16 +250,16 @@ export const FileManager = forwardRef( setIsLoading(true); if (createDialog.type === "folder") { await createSSHFolder(sessionId!, currentPath, createName, host.id); - showToast("Folder created successfully", "success"); + showToast.success("Folder created successfully"); } else { await createSSHFile(sessionId!, currentPath, createName, "", host.id); - showToast("File created successfully", "success"); + showToast.success("File created successfully"); } setCreateDialog({ visible: false, type: null }); setCreateName(""); await loadDirectory(currentPath); } catch (error: any) { - showToast(error.message || "Failed to create item", "error"); + showToast.error(error.message || "Failed to create item"); } finally { setIsLoading(false); } @@ -244,12 +276,12 @@ export const FileManager = forwardRef( try { setIsLoading(true); await renameSSHItem(sessionId!, renameDialog.file.path, renameName, host.id); - showToast("Item renamed successfully", "success"); + showToast.success("Item renamed successfully"); setRenameDialog({ visible: false, file: null }); setRenameName(""); await loadDirectory(currentPath); } catch (error: any) { - showToast(error.message || "Failed to rename item", "error"); + showToast.error(error.message || "Failed to rename item"); } finally { setIsLoading(false); } @@ -260,7 +292,7 @@ export const FileManager = forwardRef( setClipboard({ files: filesToCopy, operation: "copy" }); setSelectionMode(false); setSelectedFiles([]); - showToast(`${filesToCopy.length} item(s) copied`, "success"); + showToast.success(`${filesToCopy.length} item(s) copied`); }; const handleCut = (file?: FileItem) => { @@ -268,7 +300,7 @@ export const FileManager = forwardRef( setClipboard({ files: filesToCut, operation: "cut" }); setSelectionMode(false); setSelectedFiles([]); - showToast(`${filesToCut.length} item(s) cut`, "success"); + showToast.success(`${filesToCut.length} item(s) cut`); }; const handlePaste = async () => { @@ -283,11 +315,11 @@ export const FileManager = forwardRef( await moveSSHItem(sessionId!, filePath, joinPath(currentPath, filePath.split("/").pop()!), host.id); } } - showToast(`${clipboard.files.length} item(s) pasted`, "success"); + showToast.success(`${clipboard.files.length} item(s) pasted`); setClipboard({ files: [], operation: null }); await loadDirectory(currentPath); } catch (error: any) { - showToast(error.message || "Failed to paste items", "error"); + showToast.error(error.message || "Failed to paste items"); } finally { setIsLoading(false); } @@ -315,12 +347,12 @@ export const FileManager = forwardRef( host.id ); } - showToast(`${filesToDelete.length} item(s) deleted`, "success"); + showToast.success(`${filesToDelete.length} item(s) deleted`); setSelectionMode(false); setSelectedFiles([]); await loadDirectory(currentPath); } catch (error: any) { - showToast(error.message || "Failed to delete items", "error"); + showToast.error(error.message || "Failed to delete items"); } finally { setIsLoading(false); } @@ -365,13 +397,20 @@ export const FileManager = forwardRef( if (!isConnected) { return ( - + Connecting to {host.name}... {/* TOTP Dialog */} - + Two-Factor Authentication @@ -379,7 +418,12 @@ export const FileManager = forwardRef( Enter your TOTP code to continue ( setTotpDialog(false); setTotpCode(""); }} - className="flex-1 bg-dark-bg-darker border border-dark-border rounded py-3" + className="flex-1 bg-dark-bg-darker py-3" + style={{ + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, + borderRadius: RADIUS.BUTTON, + }} activeOpacity={0.7} > Cancel Verify @@ -417,8 +471,22 @@ export const FileManager = forwardRef( const padding = getResponsivePadding(isLandscape); const tabBarHeight = getTabBarHeight(isLandscape); + // Calculate toolbar height (only visible when in selection mode or clipboard has items) + const toolbarPaddingVertical = isLandscape ? 8 : 12; + const toolbarContentHeight = isLandscape ? 34 : 44; // Approximate content height + const toolbarBorderHeight = 2; + const effectiveToolbarHeight = (selectionMode || clipboard.files.length > 0) + ? (toolbarPaddingVertical * 2) + toolbarContentHeight + toolbarBorderHeight + : 0; + return ( - + ( onRefresh={() => loadDirectory(currentPath)} isLandscape={isLandscape} width={width} - toolbarHeight={tabBarHeight + insets.bottom} + toolbarHeight={effectiveToolbarHeight} /> ( {/* Create Dialog */} - - - - Create New {createDialog.type === "folder" ? "Folder" : "File"} - - - - { - setCreateDialog({ visible: false, type: null }); - setCreateName(""); + + + + + Create New {createDialog.type === "folder" ? "Folder" : "File"} + + - Cancel - - - Create - + value={createName} + onChangeText={setCreateName} + placeholder="Name" + placeholderTextColor="#6B7280" + autoFocus + /> + + { + setCreateDialog({ visible: false, type: null }); + setCreateName(""); + }} + className="flex-1 bg-[#27272a] py-3" + style={{ + borderWidth: 2, + borderColor: "#3f3f46", + borderRadius: 8, + }} + activeOpacity={0.7} + > + Cancel + + + Create + + - + {/* Rename Dialog */} - - - - Rename Item - - - - { - setRenameDialog({ visible: false, file: null }); - setRenameName(""); + + + + + Rename Item + + - Cancel - - - Rename - + value={renameName} + onChangeText={setRenameName} + placeholder="New name" + placeholderTextColor="#6B7280" + autoFocus + /> + + { + setRenameDialog({ visible: false, file: null }); + setRenameName(""); + }} + className="flex-1 bg-[#27272a] py-3" + style={{ + borderWidth: 2, + borderColor: "#3f3f46", + borderRadius: 8, + }} + activeOpacity={0.7} + > + Cancel + + + Rename + + - + {/* File Viewer */} diff --git a/app/Tabs/Sessions/FileManager/FileItem.tsx b/app/Tabs/Sessions/FileManager/FileItem.tsx index 7d2e9e8..fabff2e 100644 --- a/app/Tabs/Sessions/FileManager/FileItem.tsx +++ b/app/Tabs/Sessions/FileManager/FileItem.tsx @@ -32,19 +32,13 @@ export function FileItem({ const iconColor = getFileIconColor(name, type); const IconComponent = type === "directory" ? Folder : type === "link" ? Link : File; - const itemWidth = useGrid ? `${100 / columnCount - 0.5}%` : "100%"; - return ( 1; if (!isLoading && files.length === 0) { return ( @@ -69,9 +67,7 @@ export function FileList({ 0 ? toolbarHeight + 12 : 12, }} refreshControl={ onFileLongPress(file)} onSelectToggle={() => onSelectToggle(file.path)} selectionMode={selectionMode} - columnCount={columnCount} - useGrid={useGrid} /> ))} diff --git a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx index c7936d8..b29e262 100644 --- a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx +++ b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react-native"; import { breadcrumbsFromPath, getBreadcrumbLabel } from "./utils/fileUtils"; import { getResponsivePadding, getResponsiveFontSize } from "@/app/utils/responsive"; +import { BORDERS, BORDER_COLORS, BACKGROUNDS, RADIUS } from "@/app/constants/designTokens"; interface FileManagerHeaderProps { currentPath: string; @@ -45,9 +46,9 @@ export function FileManagerHeader({ return ( @@ -57,8 +58,8 @@ export function FileManagerHeader({ showsHorizontalScrollIndicator={false} style={{ paddingVertical: isLandscape ? 8 : 12, - borderBottomWidth: 1, - borderBottomColor: "#3f3f46", + borderBottomWidth: BORDERS.STANDARD, + borderBottomColor: BORDER_COLORS.SECONDARY, }} > @@ -77,16 +78,18 @@ export function FileManagerHeader({ {breadcrumbs.map((path, index) => ( - {index > 0 && ( - / + {index > 0 && breadcrumbs[index - 1] !== "/" && ( + + / + )} onNavigateToPath(path)} style={{ paddingHorizontal: 8, paddingVertical: 4, - borderRadius: 4, - backgroundColor: index === breadcrumbs.length - 1 ? "#27272a" : "transparent", + borderRadius: RADIUS.SMALL, + backgroundColor: index === breadcrumbs.length - 1 ? BACKGROUNDS.BUTTON_ALT : "transparent", }} activeOpacity={0.7} > @@ -112,10 +115,10 @@ export function FileManagerHeader({ style={{ marginRight: 8, padding: buttonPadding, - backgroundColor: "#27272a", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} disabled={isLoading} @@ -134,10 +137,10 @@ export function FileManagerHeader({ style={{ marginRight: 8, padding: buttonPadding, - backgroundColor: "#27272a", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} > @@ -149,10 +152,10 @@ export function FileManagerHeader({ style={{ marginRight: 8, padding: buttonPadding, - backgroundColor: "#27272a", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} > @@ -165,10 +168,10 @@ export function FileManagerHeader({ style={{ marginRight: 8, padding: buttonPadding, - backgroundColor: "#27272a", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} > @@ -182,10 +185,10 @@ export function FileManagerHeader({ onPress={onMenuPress} style={{ padding: buttonPadding, - backgroundColor: "#27272a", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} > diff --git a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx index 690c6a8..33f1023 100644 --- a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx +++ b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx @@ -1,6 +1,7 @@ import { View, Text, TouchableOpacity } from "react-native"; import { Copy, Scissors, Clipboard, Trash2, X } from "lucide-react-native"; import { getResponsivePadding } from "@/app/utils/responsive"; +import { BORDERS, BORDER_COLORS, BACKGROUNDS, RADIUS } from "@/app/constants/designTokens"; interface FileManagerToolbarProps { selectionMode: boolean; @@ -43,14 +44,15 @@ export function FileManagerToolbar({ {selectionMode ? ( @@ -66,10 +68,10 @@ export function FileManagerToolbar({ onPress={onCopy} style={{ padding: buttonPadding, - backgroundColor: "#18181b", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} disabled={selectedCount === 0} @@ -85,10 +87,10 @@ export function FileManagerToolbar({ onPress={onCut} style={{ padding: buttonPadding, - backgroundColor: "#18181b", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} disabled={selectedCount === 0} @@ -104,10 +106,10 @@ export function FileManagerToolbar({ onPress={onDelete} style={{ padding: buttonPadding, - backgroundColor: "#18181b", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} disabled={selectedCount === 0} @@ -124,10 +126,10 @@ export function FileManagerToolbar({ style={{ marginLeft: 8, padding: buttonPadding, - backgroundColor: "#18181b", - borderRadius: 4, - borderWidth: 1, - borderColor: "#303032", + backgroundColor: BACKGROUNDS.BUTTON_ALT, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, }} activeOpacity={0.7} > @@ -159,8 +161,8 @@ export function FileManagerToolbar({ paddingHorizontal: isLandscape ? 12 : 16, paddingVertical: isLandscape ? 6 : 8, backgroundColor: "#3B82F6", - borderRadius: 4, - borderWidth: 1, + borderRadius: RADIUS.SMALL, + borderWidth: BORDERS.STANDARD, borderColor: "#2563EB", }} activeOpacity={0.7} diff --git a/app/Tabs/Sessions/FileManager/FileViewer.tsx b/app/Tabs/Sessions/FileManager/FileViewer.tsx index dda42f8..9e37fcb 100644 --- a/app/Tabs/Sessions/FileManager/FileViewer.tsx +++ b/app/Tabs/Sessions/FileManager/FileViewer.tsx @@ -142,9 +142,9 @@ export function FileViewer({ disabled={isSaving} > {isSaving ? ( - + ) : ( - + )} @@ -211,10 +211,10 @@ export function FileViewer({ disabled={isSaving} > {isSaving ? ( - + ) : ( <> - + Save )} diff --git a/app/Tabs/Sessions/FileManager/utils/fileUtils.ts b/app/Tabs/Sessions/FileManager/utils/fileUtils.ts index 4f52b7e..b5ca8dd 100644 --- a/app/Tabs/Sessions/FileManager/utils/fileUtils.ts +++ b/app/Tabs/Sessions/FileManager/utils/fileUtils.ts @@ -167,11 +167,19 @@ export function getFileIconColor(filename: string, type: string): string { export function breadcrumbsFromPath(path: string): string[] { if (!path || path === "/") return ["/"]; - const parts = path.split("/").filter((p) => p); - const breadcrumbs = ["/"]; + + // Split and filter empty strings to handle paths properly + const parts = path.split("/").filter((p) => p.trim() !== ""); + + const breadcrumbs: string[] = ["/"]; + + // Build cumulative paths without double slashes parts.forEach((part, index) => { - breadcrumbs.push("/" + parts.slice(0, index + 1).join("/")); + const cumulativeParts = parts.slice(0, index + 1); + const breadcrumbPath = "/" + cumulativeParts.join("/"); + breadcrumbs.push(breadcrumbPath); }); + return breadcrumbs; } diff --git a/app/Tabs/Sessions/KeyboardBar.tsx b/app/Tabs/Sessions/KeyboardBar.tsx index 1f02240..d323b2a 100644 --- a/app/Tabs/Sessions/KeyboardBar.tsx +++ b/app/Tabs/Sessions/KeyboardBar.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from "react"; import { View, ScrollView, - StyleSheet, Text, Clipboard, Platform, @@ -13,6 +12,7 @@ import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationCo import { KeyConfig } from "@/types/keyboard"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { useOrientation } from "@/app/utils/orientation"; +import { BORDERS, BORDER_COLORS } from "@/app/constants/designTokens"; interface KeyboardBarProps { terminalRef: React.RefObject; @@ -128,25 +128,31 @@ export default function KeyboardBar({ return ( {hasPinnedKeys && ( <> {pinnedKeys.map((key, index) => renderKey(key, index))} - + )} @@ -155,41 +161,3 @@ export default function KeyboardBar({ ); } - -const styles = StyleSheet.create({ - keyboardBar: { - backgroundColor: "#0e0e10", - borderTopWidth: 1.5, - borderTopColor: "#303032", - }, - keyboardBarLandscape: { - borderTopWidth: 1, - }, - scrollContent: { - paddingHorizontal: 8, - paddingVertical: 8, - alignItems: "center", - gap: 6, - }, - scrollContentLandscape: { - paddingVertical: 6, - gap: 4, - }, - separator: { - width: 1, - height: 30, - backgroundColor: "#404040", - marginHorizontal: 8, - }, - hintContainer: { - paddingHorizontal: 12, - paddingBottom: 2, - paddingTop: 0, - alignItems: "center", - }, - hintText: { - fontSize: 10, - color: "#666", - fontStyle: "italic", - }, -}); diff --git a/app/Tabs/Sessions/KeyboardKey.tsx b/app/Tabs/Sessions/KeyboardKey.tsx index c67d051..e312ec8 100644 --- a/app/Tabs/Sessions/KeyboardKey.tsx +++ b/app/Tabs/Sessions/KeyboardKey.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { TouchableOpacity, Text, StyleSheet } from "react-native"; +import { TouchableOpacity, Text } from "react-native"; import * as Haptics from "expo-haptics"; import { KeySize } from "@/types/keyboard"; +import { BACKGROUNDS, BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; interface KeyboardKeyProps { label: string; @@ -42,95 +43,48 @@ export default function KeyboardKey({ } }; - const sizeStyles = getSizeStyles(keySize); + const getSizeClass = () => { + switch (keySize) { + case "small": + return "px-1.5 py-1.5 min-w-[32px] min-h-[32px]"; + case "large": + return "px-2.5 py-2.5 min-w-[42px] min-h-[42px]"; + case "medium": + default: + return "px-2 py-2 min-w-[36px] min-h-[36px]"; + } + }; + + const getTextSizeClass = () => { + switch (keySize) { + case "small": + return "text-[11px]"; + case "large": + return "text-sm"; + case "medium": + default: + return "text-xs"; + } + }; return ( - {label} + + {label} + ); } - -function getSizeStyles(size: KeySize) { - switch (size) { - case "small": - return { - button: { - paddingHorizontal: 6, - paddingVertical: 6, - minWidth: 32, - minHeight: 32, - }, - text: { - fontSize: 11, - }, - }; - case "large": - return { - button: { - paddingHorizontal: 10, - paddingVertical: 10, - minWidth: 42, - minHeight: 42, - }, - text: { - fontSize: 14, - }, - }; - case "medium": - default: - return { - button: { - paddingHorizontal: 8, - paddingVertical: 8, - minWidth: 36, - minHeight: 36, - }, - text: { - fontSize: 12, - }, - }; - } -} - -const styles = StyleSheet.create({ - keyButton: { - backgroundColor: "#2a2a2a", - borderWidth: 1, - borderColor: "#404040", - borderRadius: 6, - alignItems: "center", - justifyContent: "center", - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 2, - elevation: 1, - }, - modifierKey: { - backgroundColor: "#2a2a2a", - borderColor: "#404040", - }, - activeKey: { - backgroundColor: "#4a4a4a", - borderColor: "#606060", - shadowOpacity: 0.3, - }, - keyText: { - color: "#ffffff", - fontWeight: "500", - textAlign: "center", - }, -}); diff --git a/app/Tabs/Sessions/Navigation/TabBar.tsx b/app/Tabs/Sessions/Navigation/TabBar.tsx index fe16f4d..7e2846e 100644 --- a/app/Tabs/Sessions/Navigation/TabBar.tsx +++ b/app/Tabs/Sessions/Navigation/TabBar.tsx @@ -21,6 +21,7 @@ import { useRouter } from "expo-router"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { useOrientation } from "@/app/utils/orientation"; import { getTabBarHeight, getButtonSize } from "@/app/utils/responsive"; +import { BORDERS, BORDER_COLORS, BACKGROUNDS, RADIUS } from "@/app/constants/designTokens"; interface TabBarProps { sessions: TerminalSession[]; @@ -79,36 +80,40 @@ export default function TabBar({ } return ( - + <> + router.navigate("/hosts" as any)} focusable={false} - className="items-center justify-center rounded-md" + className="items-center justify-center" activeOpacity={0.7} style={{ width: buttonSize, height: buttonSize, - borderWidth: isLandscape ? 1.5 : 2, - borderColor: "#303032", - backgroundColor: "#2a2a2a", + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, + backgroundColor: BACKGROUNDS.BUTTON, + borderRadius: RADIUS.BUTTON, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, @@ -154,12 +159,13 @@ export default function TabBar({ key={session.id} onPress={() => onTabPress(session.id)} focusable={false} - className="flex-row items-center rounded-md" + className="flex-row items-center" style={{ - borderWidth: isLandscape ? 1.5 : 2, - borderColor: isActive ? "#22c55e" : "#303032", - backgroundColor: isActive ? "#1a1a1a" : "#1a1a1a", - shadowColor: isActive ? "#22c55e" : "transparent", + borderWidth: BORDERS.STANDARD, + borderColor: isActive ? BORDER_COLORS.ACTIVE : BORDER_COLORS.BUTTON, + backgroundColor: BACKGROUNDS.CARD, + borderRadius: RADIUS.BUTTON, + shadowColor: isActive ? BORDER_COLORS.ACTIVE : "transparent", shadowOffset: { width: 0, height: 2 }, shadowOpacity: isActive ? 0.2 : 0, shadowRadius: 4, @@ -189,8 +195,8 @@ export default function TabBar({ style={{ width: isLandscape ? 32 : 36, height: buttonSize, - borderLeftWidth: isLandscape ? 1.5 : 2, - borderLeftColor: isActive ? "#22c55e" : "#303032", + borderLeftWidth: BORDERS.STANDARD, + borderLeftColor: isActive ? BORDER_COLORS.ACTIVE : BORDER_COLORS.BUTTON, }} > onToggleKeyboard?.()} focusable={false} - className="items-center justify-center rounded-md" + className="items-center justify-center" activeOpacity={0.7} style={{ width: buttonSize, height: buttonSize, - borderWidth: isLandscape ? 1.5 : 2, - borderColor: "#303032", - backgroundColor: "#2a2a2a", + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, + backgroundColor: BACKGROUNDS.BUTTON, + borderRadius: RADIUS.BUTTON, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, @@ -260,7 +268,16 @@ export default function TabBar({ )} )} + + + - + + ); } diff --git a/app/Tabs/Sessions/ServerStats.tsx b/app/Tabs/Sessions/ServerStats.tsx index 055d5a0..be1dbc7 100644 --- a/app/Tabs/Sessions/ServerStats.tsx +++ b/app/Tabs/Sessions/ServerStats.tsx @@ -28,6 +28,7 @@ import { showToast } from "../../utils/toast"; import type { ServerMetrics } from "../../../types/index"; import { useOrientation } from "@/app/utils/orientation"; import { getResponsivePadding, getColumnCount, getTabBarHeight } from "@/app/utils/responsive"; +import { BACKGROUNDS, BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; interface ServerStatsProps { hostConfig: { @@ -116,6 +117,10 @@ export const ServerStats = forwardRef( }; }, [isVisible, fetchMetrics]); + const cardWidth = isLandscape && columnCount > 1 + ? `${(100 / columnCount) - 1}%` + : "100%"; + const formatUptime = (seconds: number | null): string => { if (seconds === null || seconds === undefined) return "N/A"; @@ -139,18 +144,14 @@ export const ServerStats = forwardRef( subtitle: string, color: string, ) => { - const cardWidth = isLandscape && columnCount > 1 - ? `${100 / columnCount - 1}%` - : "100%"; - return ( 1 ? 0 : 12, width: cardWidth, }} @@ -183,8 +184,9 @@ export const ServerStats = forwardRef( {isLoading && !metrics ? ( @@ -193,7 +195,7 @@ export const ServerStats = forwardRef( flex: 1, justifyContent: "center", alignItems: "center", - backgroundColor: "#09090b", + backgroundColor: BACKGROUNDS.DARKEST, }} > @@ -213,7 +215,7 @@ export const ServerStats = forwardRef( flex: 1, justifyContent: "center", alignItems: "center", - backgroundColor: "#09090b", + backgroundColor: BACKGROUNDS.DARKEST, paddingHorizontal: 24, }} > @@ -245,7 +247,7 @@ export const ServerStats = forwardRef( backgroundColor: "#22C55E", paddingHorizontal: 24, paddingVertical: 12, - borderRadius: 8, + borderRadius: RADIUS.BUTTON, marginTop: 24, }} > @@ -259,12 +261,10 @@ export const ServerStats = forwardRef( style={{ flex: 1 }} contentContainerStyle={{ padding, + paddingTop: padding / 2, paddingLeft: Math.max(insets.left, padding), paddingRight: Math.max(insets.right, padding), - paddingBottom: tabBarHeight + insets.bottom + 12, - flexDirection: isLandscape && columnCount > 1 ? "row" : "column", - flexWrap: "wrap", - gap: 12, + paddingBottom: padding, }} refreshControl={ ( } > {/* Header */} - + {hostConfig.name} @@ -285,120 +285,110 @@ export const ServerStats = forwardRef( - {/* CPU Metrics */} - {renderMetricCard( - , - "CPU Usage", - typeof metrics?.cpu?.percent === "number" - ? `${metrics.cpu.percent}%` - : "N/A", - typeof metrics?.cpu?.cores === "number" - ? `${metrics.cpu.cores} cores` - : "N/A", - "#60A5FA", - )} + {/* Grid Container */} + 1 ? "row" : "column", + flexWrap: "wrap", + gap: 12, + }} + > + {/* CPU Metrics */} + {renderMetricCard( + , + "CPU Usage", + typeof metrics?.cpu?.percent === "number" + ? `${metrics.cpu.percent}%` + : "N/A", + typeof metrics?.cpu?.cores === "number" + ? `${metrics.cpu.cores} cores` + : "N/A", + "#60A5FA", + )} - {/* Load Average */} - {metrics?.cpu?.load && ( - - - - - Load Average - - - - - - {metrics.cpu.load[0].toFixed(2)} - - - 1 min + {/* Load Average */} + {metrics?.cpu?.load && ( + 1 ? 0 : 12, + width: cardWidth, + }} + > + + + + Load Average - - - {metrics.cpu.load[1].toFixed(2)} - - - 5 min - - - - - {metrics.cpu.load[2].toFixed(2)} - - - 15 min - + + + + {metrics.cpu.load[0].toFixed(2)} + + + 1 min + + + + + {metrics.cpu.load[1].toFixed(2)} + + + 5 min + + + + + {metrics.cpu.load[2].toFixed(2)} + + + 15 min + + - - )} + )} - {/* Memory Metrics */} - {renderMetricCard( - , - "Memory Usage", - typeof metrics?.memory?.percent === "number" - ? `${metrics.memory.percent}%` - : "N/A", - (() => { - const used = metrics?.memory?.usedGiB; - const total = metrics?.memory?.totalGiB; - if (typeof used === "number" && typeof total === "number") { - return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`; - } - return "N/A"; - })(), - "#34D399", - )} + {/* Memory Metrics */} + {renderMetricCard( + , + "Memory Usage", + typeof metrics?.memory?.percent === "number" + ? `${metrics.memory.percent}%` + : "N/A", + (() => { + const used = metrics?.memory?.usedGiB; + const total = metrics?.memory?.totalGiB; + if (typeof used === "number" && typeof total === "number") { + return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`; + } + return "N/A"; + })(), + "#34D399", + )} - {/* Disk Metrics */} - {renderMetricCard( - , - "Disk Usage", - typeof metrics?.disk?.percent === "number" - ? `${metrics.disk.percent}%` - : "N/A", - (() => { - const used = metrics?.disk?.usedHuman; - const total = metrics?.disk?.totalHuman; - if (used && total) { - return `${used} / ${total}`; - } - return "N/A"; - })(), - "#F59E0B", - )} - - {/* Last Updated */} - - - - Last updated: {new Date(metrics?.lastChecked || "").toLocaleTimeString()} - + {/* Disk Metrics */} + {renderMetricCard( + , + "Disk Usage", + typeof metrics?.disk?.percent === "number" + ? `${metrics.disk.percent}%` + : "N/A", + (() => { + const used = metrics?.disk?.usedHuman; + const total = metrics?.disk?.totalHuman; + if (used && total) { + return `${used} / ${total}`; + } + return "N/A"; + })(), + "#F59E0B", + )} )} diff --git a/app/Tabs/Sessions/Sessions.tsx b/app/Tabs/Sessions/Sessions.tsx index 1ccb6da..3a54064 100644 --- a/app/Tabs/Sessions/Sessions.tsx +++ b/app/Tabs/Sessions/Sessions.tsx @@ -28,7 +28,7 @@ import BottomToolbar from "@/app/Tabs/Sessions/BottomToolbar"; import KeyboardBar from "@/app/Tabs/Sessions/KeyboardBar"; import { ArrowLeft } from "lucide-react-native"; import { useOrientation } from "@/app/utils/orientation"; -import { getMaxKeyboardHeight } from "@/app/utils/responsive"; +import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; export default function Sessions() { const insets = useSafeAreaInsets(); @@ -75,18 +75,32 @@ export default function Sessions() { ? Math.min(keyboardHeight, maxKeyboardHeight) : keyboardHeight; + // BottomToolbar height includes tab bar + content + safe area insets + const TAB_BAR_HEIGHT = 36; + const bottomToolbarHeight = isCustomKeyboardVisible + ? TAB_BAR_HEIGHT + effectiveKeyboardHeight + insets.bottom + : 0; + // Calculate bottom margins for content - const getBottomMargin = () => { - const tabBarHeight = 60; + const getBottomMargin = (sessionType: "terminal" | "stats" | "filemanager" = "terminal") => { + const sessionTabBarHeight = getTabBarHeight(isLandscape); + + // For non-terminal sessions, use simple tab bar height + safe area + if (sessionType !== "terminal") { + return sessionTabBarHeight + insets.bottom; + } + + // Terminal-specific logic with keyboard handling const keyboardBarHeight = 50; - const baseMargin = tabBarHeight + keyboardBarHeight + 5; + const baseMargin = sessionTabBarHeight + keyboardBarHeight; if (keyboardIntentionallyHiddenRef.current) { - return 126; + return sessionTabBarHeight + 66; // 66 is the larger keyboard bar height when hidden } if (isCustomKeyboardVisible) { - return effectiveKeyboardHeight + baseMargin; + // Custom keyboard: session tab bar + keyboard bar + TAB_BAR_HEIGHT + keyboard content + return sessionTabBarHeight + keyboardBarHeight + TAB_BAR_HEIGHT + effectiveKeyboardHeight; } if (isKeyboardVisible && currentKeyboardHeight > 0) { @@ -345,7 +359,7 @@ export default function Sessions() { {sessions.map((session) => { @@ -390,23 +404,13 @@ export default function Sessions() { ); } else if (session.type === "filemanager") { return ( - - - + ref={fileManagerRefs.current[session.id]} + host={session.host} + sessionId={session.id} + isVisible={session.id === activeSessionId} + /> ); } return null; @@ -500,21 +504,6 @@ export default function Sessions() { )} - {sessions.length > 0 && activeSession?.type === "terminal" && ( - - )} - {sessions.length > 0 && activeSession?.type === "terminal" && ( 0 ? currentKeyboardHeight : 0, left: 0, right: 0, - height: keyboardIntentionallyHiddenRef.current ? 66 : 50, + height: keyboardIntentionallyHiddenRef.current ? 66 : 52, zIndex: 1003, + overflow: "visible", }} > 0 ? currentKeyboardHeight + 50 : 50 - : 0, + : 32, left: 0, right: 0, height: 60, @@ -716,6 +706,14 @@ export default function Sessions() { onFocus={() => { setKeyboardIntentionallyHidden(false); }} + onBlur={() => { + // Immediately refocus if keyboard wasn't intentionally hidden + if (!keyboardIntentionallyHiddenRef.current && !isCustomKeyboardVisible && activeSession?.type === "terminal") { + setTimeout(() => { + hiddenInputRef.current?.focus(); + }, 0); + } + }} /> )} diff --git a/app/Tabs/Sessions/SnippetsBar.tsx b/app/Tabs/Sessions/SnippetsBar.tsx index 2107acd..2996112 100644 --- a/app/Tabs/Sessions/SnippetsBar.tsx +++ b/app/Tabs/Sessions/SnippetsBar.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from "react"; import { View, Text, - StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, @@ -10,6 +9,7 @@ import { import { TerminalHandle } from "./Terminal"; import { getSnippets, getSnippetFolders } from "@/app/main-axios"; import { showToast } from "@/app/utils/toast"; +import { BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; interface Snippet { id: number; @@ -68,7 +68,7 @@ export default function SnippetsBar({ ) ); } catch (error) { - showToast("Failed to load snippets", "error"); + showToast.error("Failed to load snippets"); } finally { setLoading(false); } @@ -77,7 +77,7 @@ export default function SnippetsBar({ const executeSnippet = (snippet: Snippet) => { if (terminalRef.current) { terminalRef.current.sendInput(snippet.content + "\n"); - showToast(`Executed: ${snippet.name}`, "success"); + showToast.success(`Executed: ${snippet.name}`); } }; @@ -101,8 +101,8 @@ export default function SnippetsBar({ if (loading) { return ( - - + + ); } @@ -110,26 +110,41 @@ export default function SnippetsBar({ const unfolderedSnippets = getSnippetsInFolder(null); return ( - - - Snippets - - + + + Snippets + + {unfolderedSnippets.map((snippet) => ( executeSnippet(snippet)} > - + {snippet.name} @@ -140,24 +155,28 @@ export default function SnippetsBar({ const isCollapsed = collapsedFolders.has(folder.id); return ( - + toggleFolder(folder.id)} > - + {folder.icon && ( - {folder.icon} + {folder.icon} )} - + {folder.name} - ({folderSnippets.length}) + ({folderSnippets.length}) - + {isCollapsed ? "▶" : "▼"} @@ -166,10 +185,15 @@ export default function SnippetsBar({ folderSnippets.map((snippet) => ( executeSnippet(snippet)} > - + {snippet.name} @@ -179,9 +203,9 @@ export default function SnippetsBar({ })} {snippets.length === 0 && ( - - No snippets yet - + + No snippets yet + Create snippets in Settings @@ -190,119 +214,3 @@ export default function SnippetsBar({ ); } - -const styles = StyleSheet.create({ - container: { - backgroundColor: "#0e0e10", - borderTopWidth: 1.5, - borderTopColor: "#303032", - }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingHorizontal: 12, - paddingVertical: 8, - borderBottomWidth: 1, - borderBottomColor: "#303032", - }, - headerText: { - fontSize: 14, - fontWeight: "600", - color: "#e5e5e7", - }, - refreshButton: { - padding: 4, - }, - refreshText: { - fontSize: 18, - color: "#9333ea", - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 8, - paddingVertical: 8, - }, - snippetItem: { - backgroundColor: "#18181b", - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 6, - marginBottom: 6, - borderWidth: 1, - borderColor: "#303032", - }, - snippetItemInFolder: { - backgroundColor: "#18181b", - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 6, - marginBottom: 6, - marginLeft: 16, - borderWidth: 1, - borderColor: "#303032", - }, - snippetName: { - fontSize: 13, - color: "#e5e5e7", - fontWeight: "500", - }, - folderContainer: { - marginBottom: 8, - }, - folderHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - backgroundColor: "#18181b", - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 6, - marginBottom: 6, - borderWidth: 1, - borderColor: "#303032", - borderLeftWidth: 3, - borderLeftColor: "#9333ea", - }, - folderHeaderContent: { - flexDirection: "row", - alignItems: "center", - flex: 1, - }, - folderIcon: { - fontSize: 16, - marginRight: 8, - }, - folderName: { - fontSize: 14, - fontWeight: "600", - color: "#e5e5e7", - flex: 1, - }, - folderCount: { - fontSize: 12, - color: "#888", - marginLeft: 4, - }, - collapseIcon: { - fontSize: 10, - color: "#888", - marginLeft: 8, - }, - emptyContainer: { - paddingVertical: 32, - alignItems: "center", - }, - emptyText: { - fontSize: 14, - color: "#888", - fontWeight: "600", - }, - emptySubtext: { - fontSize: 12, - color: "#666", - marginTop: 4, - }, -}); diff --git a/app/Tabs/Sessions/Terminal.tsx b/app/Tabs/Sessions/Terminal.tsx index 55a6ef4..ce684f0 100644 --- a/app/Tabs/Sessions/Terminal.tsx +++ b/app/Tabs/Sessions/Terminal.tsx @@ -252,33 +252,29 @@ const TerminalComponent = forwardRef( scrollbar-color: rgba(180,180,180,0.7) transparent; } /* Disable text selection and callouts to avoid native dialogues */ - * { -webkit-tap-highlight-color: transparent; } - html, body, #terminal, .xterm * { + * { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + } + html, body, #terminal, .xterm, .xterm * { user-select: none; -webkit-user-select: none; -ms-user-select: none; + -moz-user-select: none; } - /* Hidden input for better keyboard handling */ - #hidden-input { - position: absolute; - left: -9999px; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; + /* Prevent all input elements from being focusable but keep them in DOM */ + input, textarea, [contenteditable], .xterm-helper-textarea { + position: absolute !important; + left: -9999px !important; + width: 1px !important; + height: 1px !important; + opacity: 0 !important; } + -
@@ -842,9 +766,7 @@ const TerminalComponent = forwardRef( > {/* Note: Hidden TextInput removed - keyboard handled by Sessions.tsx */} - - - ( allowsInlineMediaPlayback={true} mediaPlaybackRequiresUserAction={false} keyboardDisplayRequiresUserAction={false} + hideKeyboardAccessoryView={true} onScroll={() => {}} onMessage={handleWebViewMessage} onError={(syntheticEvent) => { @@ -876,15 +799,12 @@ const TerminalComponent = forwardRef( `WebView HTTP error: ${nativeEvent.statusCode}`, ); }} - scrollEnabled={false} + scrollEnabled={true} bounces={false} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} nestedScrollEnabled={false} - /> - - - + />
{(showConnectingOverlay || isRetrying) && ( ( - + Keyboard Presets @@ -316,7 +317,7 @@ export default function KeyboardCustomization() { )} - +
); const validateTopBarDrag = (newData: UnifiedListItem[]): boolean => { @@ -514,7 +515,7 @@ export default function KeyboardCustomization() { ); const renderSettings = () => ( - + Keyboard Settings @@ -609,7 +610,7 @@ export default function KeyboardCustomization() { Reset Everything to Default
-
+ ); return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index 0e9c15b..5716936 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,10 +16,13 @@ import UpdateRequired from "@/app/Authentication/UpdateRequired"; function RootLayoutContent() { const { showServerManager, + setShowServerManager, showLoginForm, + setShowLoginForm, isAuthenticated, showUpdateScreen, isLoading, + setIsLoading, } = useAppContext(); if (isLoading) { diff --git a/tailwind.config.js b/tailwind.config.js index 6debdd9..1997085 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -27,6 +27,11 @@ module.exports = { 'dark-border-panel': '#222224', 'dark-bg-panel-hover': '#232327', }, + borderRadius: { + 'button': '6px', + 'card': '12px', + 'small': '4px', + }, }, }, plugins: [], From 8ee41770e082857f2ba16686e01e0bb90e9d928a Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 4 Dec 2025 00:54:23 -0600 Subject: [PATCH 04/27] feat: Improved file manager editor/styling --- app/Tabs/Sessions/FileManager.tsx | 9 +- app/Tabs/Sessions/FileManager/ContextMenu.tsx | 25 +-- .../FileManager/FileManagerToolbar.tsx | 78 ++++--- app/Tabs/Sessions/FileManager/FileViewer.tsx | 193 +++++++++--------- app/constants/designTokens.ts | 67 ++++++ package-lock.json | 32 +++ 6 files changed, 254 insertions(+), 150 deletions(-) create mode 100644 app/constants/designTokens.ts diff --git a/app/Tabs/Sessions/FileManager.tsx b/app/Tabs/Sessions/FileManager.tsx index 4fd17d1..8764a4a 100644 --- a/app/Tabs/Sessions/FileManager.tsx +++ b/app/Tabs/Sessions/FileManager.tsx @@ -198,10 +198,8 @@ export const FileManager = forwardRef( // Handle regular files and directories if (file.type === "directory") { loadDirectory(file.path); - } else if (isTextFile(file.name)) { - handleViewFile(file); } else { - showToast.info("File type not supported for viewing"); + handleViewFile(file); } }; @@ -520,6 +518,7 @@ export const FileManager = forwardRef( onPaste={handlePaste} onDelete={() => handleDelete()} onCancelSelection={handleCancelSelection} + onCancelClipboard={() => setClipboard({ files: [], operation: null })} clipboardCount={clipboard.files.length} clipboardOperation={clipboard.operation} isLandscape={isLandscape} @@ -535,12 +534,12 @@ export const FileManager = forwardRef( fileName={contextMenu.file.name} fileType={contextMenu.file.type} onView={ - isTextFile(contextMenu.file.name) + contextMenu.file.type === "file" ? () => handleViewFile(contextMenu.file!) : undefined } onEdit={ - isTextFile(contextMenu.file.name) + contextMenu.file.type === "file" ? () => handleViewFile(contextMenu.file!) : undefined } diff --git a/app/Tabs/Sessions/FileManager/ContextMenu.tsx b/app/Tabs/Sessions/FileManager/ContextMenu.tsx index e78f8e1..0f27958 100644 --- a/app/Tabs/Sessions/FileManager/ContextMenu.tsx +++ b/app/Tabs/Sessions/FileManager/ContextMenu.tsx @@ -65,6 +65,7 @@ export function ContextMenu({ transparent animationType="fade" onRequestClose={onClose} + supportedOrientations={['portrait', 'landscape']} > @@ -91,7 +92,7 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + View
)} @@ -102,7 +103,7 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + Edit
)} @@ -112,7 +113,7 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + Rename @@ -121,7 +122,7 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + Copy @@ -130,7 +131,7 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + Cut @@ -140,7 +141,7 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + Download )} @@ -151,7 +152,7 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + Permissions )} @@ -162,7 +163,7 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + Compress )} @@ -173,18 +174,18 @@ export function ContextMenu({ className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - + Extract )} handleAction(onDelete)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-red-500" + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - - Delete + + Delete
diff --git a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx index 33f1023..cfea09d 100644 --- a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx +++ b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx @@ -11,6 +11,7 @@ interface FileManagerToolbarProps { onPaste: () => void; onDelete: () => void; onCancelSelection: () => void; + onCancelClipboard?: () => void; clipboardCount?: number; clipboardOperation?: "copy" | "cut" | null; isLandscape: boolean; @@ -26,6 +27,7 @@ export function FileManagerToolbar({ onPaste, onDelete, onCancelSelection, + onCancelClipboard, clipboardCount = 0, clipboardOperation = null, isLandscape, @@ -39,12 +41,14 @@ export function FileManagerToolbar({ const padding = getResponsivePadding(isLandscape); const iconSize = isLandscape ? 18 : 20; const buttonPadding = isLandscape ? 6 : 8; + // Position above tab bar: in portrait we need more space, in landscape it's closer + const bottomPosition = isLandscape ? bottomInset : 0; return ( @@ -97,7 +101,7 @@ export function FileManagerToolbar({ > @@ -116,7 +120,7 @@ export function FileManagerToolbar({ > @@ -133,43 +137,51 @@ export function FileManagerToolbar({ }} activeOpacity={0.7} > - +
) : ( {/* Clipboard info */} - - {clipboardOperation === "copy" ? ( - - ) : ( - + + {clipboardCount} item{clipboardCount !== 1 ? "s" : ""}{" "} + {clipboardOperation === "copy" ? "copied" : "cut"} + + + + {/* Paste */} + + + + + {/* Cancel */} + {onCancelClipboard && ( + + + )} - - {clipboardCount} item{clipboardCount !== 1 ? "s" : ""}{" "} - {clipboardOperation === "copy" ? "copied" : "cut"} - - - {/* Paste button */} - - - Paste - )} diff --git a/app/Tabs/Sessions/FileManager/FileViewer.tsx b/app/Tabs/Sessions/FileManager/FileViewer.tsx index 9e37fcb..accfeac 100644 --- a/app/Tabs/Sessions/FileManager/FileViewer.tsx +++ b/app/Tabs/Sessions/FileManager/FileViewer.tsx @@ -5,11 +5,15 @@ import { Text, TouchableOpacity, TextInput, - ScrollView, ActivityIndicator, Alert, + Platform, + KeyboardAvoidingView, } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { X, Save, RotateCcw } from "lucide-react-native"; +import { showToast } from "@/app/utils/toast"; +import { useOrientation } from "@/app/utils/orientation"; interface FileViewerProps { visible: boolean; @@ -21,6 +25,12 @@ interface FileViewerProps { readOnly?: boolean; } +const MONOSPACE_FONT = Platform.select({ + ios: 'Courier', + android: 'monospace', + default: 'monospace' +}); + export function FileViewer({ visible, onClose, @@ -30,6 +40,8 @@ export function FileViewer({ onSave, readOnly = false, }: FileViewerProps) { + const insets = useSafeAreaInsets(); + const { isLandscape } = useOrientation(); const [content, setContent] = useState(initialContent); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); @@ -51,9 +63,8 @@ export function FileViewer({ setIsSaving(true); await onSave(content); setHasChanges(false); - Alert.alert("Success", "File saved successfully"); } catch (error: any) { - Alert.alert("Error", error.message || "Failed to save file"); + showToast.error(error.message || "Failed to save file"); } finally { setIsSaving(false); } @@ -110,119 +121,101 @@ export function FileViewer({ visible={visible} animationType="slide" onRequestClose={handleClose} + supportedOrientations={['portrait', 'landscape']} > - - {/* Header */} - - - - - {fileName} - - - {filePath} - - - - - {!readOnly && hasChanges && ( - <> - - - - - - {isSaving ? ( - - ) : ( - - )} - - - )} - - - - - - + + + {/* Header */} + + + + + {fileName} + + + {filePath} + + + + + {!readOnly && hasChanges && ( + <> + + + + + + {isSaving ? ( + + ) : ( + + )} + + + )} - {hasChanges && !readOnly && ( - - Unsaved changes + + + + - )} - {readOnly && ( - - Read-only mode - - )} - + {readOnly && ( + + Read-only mode + + )} + - {/* Content */} - + {/* Code Editor */} - - - {/* Bottom buttons (mobile-friendly) */} - {!readOnly && hasChanges && ( - - - - - Revert - - - - {isSaving ? ( - - ) : ( - <> - - Save - - )} - - - - )} - + + ); } diff --git a/app/constants/designTokens.ts b/app/constants/designTokens.ts new file mode 100644 index 0000000..f8dc108 --- /dev/null +++ b/app/constants/designTokens.ts @@ -0,0 +1,67 @@ +/** + * Centralized design tokens for Termix Mobile Sessions UI + * Ensures visual consistency across all components + */ + +// Border widths +export const BORDERS = { + MAJOR: 2, // TabBar, BottomToolbar, FileManagerHeader, FileManagerToolbar + STANDARD: 1, // Buttons, cards, internal elements + SEPARATOR: 1, // KeyboardBar separator, breadcrumb divider +} as const; + +// Border colors +export const BORDER_COLORS = { + PRIMARY: '#303032', // Main borders (major boundaries) + SECONDARY: '#373739', // Secondary borders (internal dividers) + SEPARATOR: '#404040', // Separators (keyboard divider, etc.) + BUTTON: '#303032', // Button borders + ACTIVE: '#22C55E', // Active/selected state +} as const; + +// Background colors +export const BACKGROUNDS = { + DARKEST: '#09090b', // Terminal, ServerStats main bg + DARKER: '#0e0e10', // TabBar + HEADER: '#131316', // FileManagerHeader, FileManagerToolbar + DARK: '#18181b', // FileManager, general dark bg + CARD: '#1a1a1a', // ServerStats cards, file items + BUTTON: '#2a2a2a', // Standard button background + BUTTON_ALT: '#23232a', // Alternative button background (FileManager) + ACTIVE: '#4a4a4a', // Active button state + HOVER: '#2d2d30', // Hover state +} as const; + +// Border radius +export const RADIUS = { + BUTTON: 6, // Standard button radius + CARD: 12, // Card/panel radius + SMALL: 4, // Small elements (breadcrumb, tiny buttons) + LARGE: 16, // Modals, large panels +} as const; + +// Spacing +export const SPACING = { + TOOLBAR_PADDING_PORTRAIT: 12, + TOOLBAR_PADDING_LANDSCAPE: 8, + BUTTON_PADDING_PORTRAIT: 8, + BUTTON_PADDING_LANDSCAPE: 6, + CARD_GAP: 12, + BUTTON_GAP: 8, +} as const; + +// Text colors +export const TEXT_COLORS = { + PRIMARY: '#ffffff', + SECONDARY: '#9CA3AF', + TERTIARY: '#6B7280', + DISABLED: '#4B5563', + ACCENT: '#22C55E', +} as const; + +// Icon sizes +export const ICON_SIZES = { + SMALL: 16, // Landscape mode + MEDIUM: 18, // Standard + LARGE: 20, // Portrait mode, important actions +} as const; diff --git a/package-lock.json b/package-lock.json index b4f9583..b515cf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,6 +112,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1493,6 +1494,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1746,6 +1748,7 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3969,6 +3972,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.17.tgz", "integrity": "sha512-uEcYWi1NV+2Qe1oELfp9b5hTYekqWATv2cuwcOAg5EvsIsUPtzFrKIasgUXLBRGb9P7yR5ifoJ+ug4u6jdqSTQ==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.12.4", "escape-string-regexp": "^4.0.0", @@ -4162,6 +4166,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.3.tgz", "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -4172,6 +4177,7 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4243,6 +4249,7 @@ "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/types": "8.43.0", @@ -4805,6 +4812,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5535,6 +5543,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.2", "caniuse-lite": "^1.0.30001741", @@ -6983,6 +6992,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7076,6 +7086,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7195,6 +7206,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7456,6 +7468,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.13.tgz", "integrity": "sha512-F1puKXzw8ESnsbvaKdXtcIiyYLQ2kUHqP8LuhgtJS1wm6w55VhtOPg8yl/0i8kPbTA0YfD+KYdXjSfhPXgUPxw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.11", @@ -7570,6 +7583,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.9.tgz", "integrity": "sha512-sqoXHAOGDcr+M9NlXzj1tGoZyd3zxYDy215W6E0Z0n8fgBaqce9FAYQE2bu5X4G629AYig5go7U6sQz7Pjcm8A==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~12.0.9", "@expo/env": "~2.0.7" @@ -7644,6 +7658,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz", "integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -7700,6 +7715,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz", "integrity": "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" @@ -11758,6 +11774,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -11902,6 +11919,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12228,6 +12246,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -12280,6 +12299,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.4", @@ -12634,6 +12654,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -12659,6 +12680,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.2.tgz", "integrity": "sha512-qzmQiFrvjm62pRBcj97QI9Xckc3EjgHQoY1F2yjktd0kpjhoyePeuTEXjYRCAVIy7IV/1cfeSup34+zFThFoHQ==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -12687,6 +12709,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.1.tgz", "integrity": "sha512-/wJE58HLEAkATzhhX1xSr+fostLsK8Q97EfpfMDKo8jlOc1QKESSX/FQrhk7HhQH/2uSaox4Y86sNaI02kteiA==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -12697,6 +12720,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -12712,6 +12736,7 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.14.0.tgz", "integrity": "sha512-B3gYc7WztcOT4N54AtUutbe0Nuqqh/nkresY0fAXzUHYLsWuIu/yGiCCD3DKfAs6GLv5LFtWTu7N333Q+e3bkg==", "license": "MIT", + "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -12727,6 +12752,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.1.tgz", "integrity": "sha512-BeNsgwwe4AXUFPAoFU+DKjJ+CVQa3h54zYX77p7GVZrXiiNo3vl03WYDYVEy5R2J2HOPInXtQZB5gmj3vuzrKg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -12759,6 +12785,7 @@ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==", "license": "MIT", + "peer": true, "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" @@ -12860,6 +12887,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14192,6 +14220,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -14425,6 +14454,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14629,6 +14659,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15426,6 +15457,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From f69db496079acd7d8fb150f37fb5546f4c2705b4 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 6 Dec 2025 02:23:48 -0600 Subject: [PATCH 05/27] fix: Run npm linter and improve styling/UI bugs --- app/Authentication/LoginForm.tsx | 8 +- app/Tabs/Hosts/Navigation/Host.tsx | 7 +- app/Tabs/Sessions/BottomToolbar.tsx | 24 +- app/Tabs/Sessions/CommandHistoryBar.tsx | 27 +- app/Tabs/Sessions/FileManager.tsx | 183 +++++--- app/Tabs/Sessions/FileManager/ContextMenu.tsx | 7 +- app/Tabs/Sessions/FileManager/FileItem.tsx | 21 +- .../FileManager/FileManagerHeader.tsx | 44 +- .../FileManager/FileManagerToolbar.tsx | 45 +- app/Tabs/Sessions/FileManager/FileViewer.tsx | 35 +- .../Sessions/FileManager/utils/fileUtils.ts | 61 ++- app/Tabs/Sessions/KeyboardBar.tsx | 17 +- app/Tabs/Sessions/KeyboardKey.tsx | 11 +- app/Tabs/Sessions/Navigation/TabBar.tsx | 66 +-- app/Tabs/Sessions/ServerStats.tsx | 117 +++-- app/Tabs/Sessions/Sessions.tsx | 435 ++++++++++-------- app/Tabs/Sessions/SnippetsBar.tsx | 43 +- app/Tabs/Sessions/Terminal.tsx | 142 ++---- app/constants/designTokens.ts | 58 +-- app/contexts/TerminalSessionsContext.tsx | 122 +++-- app/main-axios.ts | 83 +--- app/utils/orientation.ts | 8 +- app/utils/responsive.ts | 32 +- 23 files changed, 938 insertions(+), 658 deletions(-) diff --git a/app/Authentication/LoginForm.tsx b/app/Authentication/LoginForm.tsx index fa6ae18..2e3b970 100644 --- a/app/Authentication/LoginForm.tsx +++ b/app/Authentication/LoginForm.tsx @@ -394,9 +394,11 @@ export default function LoginForm() { key={webViewKey} ref={webViewRef} source={source} - userAgent={Platform.OS === "android" - ? "Termix-Mobile/Android" - : "Termix-Mobile/iOS"} + userAgent={ + Platform.OS === "android" + ? "Termix-Mobile/Android" + : "Termix-Mobile/iOS" + } style={{ flex: 1, backgroundColor: "#18181b" }} containerStyle={{ backgroundColor: "#18181b" }} onNavigationStateChange={handleNavigationStateChange} diff --git a/app/Tabs/Hosts/Navigation/Host.tsx b/app/Tabs/Hosts/Navigation/Host.tsx index f728a59..e50af46 100644 --- a/app/Tabs/Hosts/Navigation/Host.tsx +++ b/app/Tabs/Hosts/Navigation/Host.tsx @@ -309,7 +309,7 @@ function Host({ host, status, isLast = false }: HostProps) { transparent={true} animationType="fade" onRequestClose={handleCloseContextMenu} - supportedOrientations={['portrait', 'landscape']} + supportedOrientations={["portrait", "landscape"]} > @@ -372,10 +372,7 @@ function Host({ host, status, isLast = false }: HostProps) { View Server Stats - + Monitor CPU, memory, and disk usage diff --git a/app/Tabs/Sessions/BottomToolbar.tsx b/app/Tabs/Sessions/BottomToolbar.tsx index a8aa6d1..49feb58 100644 --- a/app/Tabs/Sessions/BottomToolbar.tsx +++ b/app/Tabs/Sessions/BottomToolbar.tsx @@ -4,17 +4,19 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { TerminalHandle } from "./Terminal"; import CustomKeyboard from "./CustomKeyboard"; import SnippetsBar from "./SnippetsBar"; -import CommandHistoryBar from "./CommandHistoryBar"; -import { BORDERS, BORDER_COLORS, BACKGROUNDS } from "@/app/constants/designTokens"; +import { + BORDERS, + BORDER_COLORS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; -type ToolbarMode = "keyboard" | "snippets" | "history"; +type ToolbarMode = "keyboard" | "snippets"; interface BottomToolbarProps { terminalRef: React.RefObject; isVisible: boolean; keyboardHeight: number; isKeyboardIntentionallyHidden?: boolean; - currentHostId?: number; } export default function BottomToolbar({ @@ -22,7 +24,6 @@ export default function BottomToolbar({ isVisible, keyboardHeight, isKeyboardIntentionallyHidden = false, - currentHostId, }: BottomToolbarProps) { const [mode, setMode] = useState("keyboard"); const insets = useSafeAreaInsets(); @@ -35,7 +36,6 @@ export default function BottomToolbar({ const tabs: { id: ToolbarMode; label: string }[] = [ { id: "keyboard", label: "KEYBOARD" }, { id: "snippets", label: "SNIPPETS" }, - { id: "history", label: "HISTORY" }, ]; // Total height includes tab bar + content area (padding handled separately) @@ -64,7 +64,8 @@ export default function BottomToolbar({ className="flex-1 items-center justify-center py-1.5 px-1 bg-dark-bg-darkest" onPress={() => setMode(tab.id)} style={{ - borderRightWidth: index !== tabs.length - 1 ? BORDERS.STANDARD : 0, + borderRightWidth: + index !== tabs.length - 1 ? BORDERS.STANDARD : 0, borderRightColor: BORDER_COLORS.SECONDARY, }} > @@ -109,15 +110,6 @@ export default function BottomToolbar({ height={safeKeyboardHeight} /> )} - - {mode === "history" && ( - - )}
); diff --git a/app/Tabs/Sessions/CommandHistoryBar.tsx b/app/Tabs/Sessions/CommandHistoryBar.tsx index e54d9d2..ddac03b 100644 --- a/app/Tabs/Sessions/CommandHistoryBar.tsx +++ b/app/Tabs/Sessions/CommandHistoryBar.tsx @@ -39,7 +39,7 @@ export default function CommandHistoryBar({ }: CommandHistoryBarProps) { const [history, setHistory] = useState([]); const [filteredHistory, setFilteredHistory] = useState( - [] + [], ); const [searchQuery, setSearchQuery] = useState(""); const [loading, setLoading] = useState(true); @@ -68,7 +68,7 @@ export default function CommandHistoryBar({ // Sort by timestamp descending (most recent first) const sortedHistory = historyData.sort( (a: CommandHistoryItem, b: CommandHistoryItem) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), ); setHistory(sortedHistory); @@ -91,7 +91,7 @@ export default function CommandHistoryBar({ if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = filtered.filter((item) => - item.command.toLowerCase().includes(query) + item.command.toLowerCase().includes(query), ); } @@ -160,7 +160,9 @@ export default function CommandHistoryBar({ borderBottomColor: BORDER_COLORS.SECONDARY, }} > - Command History + + Command History + @@ -219,11 +221,16 @@ export default function CommandHistoryBar({ className="flex-1 px-3 py-2.5" onPress={() => executeCommand(item.command)} > - + {item.command} - {item.hostName} + + {item.hostName} + {formatTimestamp(item.timestamp)} @@ -240,7 +247,9 @@ export default function CommandHistoryBar({ {filteredHistory.length === 0 && !searchQuery && ( - No command history yet + + No command history yet + Commands you run will appear here @@ -249,7 +258,9 @@ export default function CommandHistoryBar({ {filteredHistory.length === 0 && searchQuery && ( - No matching commands + + No matching commands + Try a different search term diff --git a/app/Tabs/Sessions/FileManager.tsx b/app/Tabs/Sessions/FileManager.tsx index 8764a4a..89054e2 100644 --- a/app/Tabs/Sessions/FileManager.tsx +++ b/app/Tabs/Sessions/FileManager.tsx @@ -1,10 +1,32 @@ -import { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from "react"; -import { View, Alert, TextInput, Modal, Text, TouchableOpacity, ActivityIndicator, KeyboardAvoidingView, Platform } from "react-native"; +import { + useState, + useEffect, + useRef, + useCallback, + forwardRef, + useImperativeHandle, +} from "react"; +import { + View, + Alert, + TextInput, + Modal, + Text, + TouchableOpacity, + ActivityIndicator, + KeyboardAvoidingView, + Platform, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SSHHost } from "@/types"; import { useOrientation } from "@/app/utils/orientation"; import { getResponsivePadding, getTabBarHeight } from "@/app/utils/responsive"; -import { BORDERS, BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; import { connectSSH, listSSHFiles, @@ -25,7 +47,11 @@ import { FileManagerHeader } from "./FileManager/FileManagerHeader"; import { FileManagerToolbar } from "./FileManager/FileManagerToolbar"; import { ContextMenu } from "./FileManager/ContextMenu"; import { FileViewer } from "./FileManager/FileViewer"; -import { joinPath, isTextFile, isArchiveFile } from "./FileManager/utils/fileUtils"; +import { + joinPath, + isTextFile, + isArchiveFile, +} from "./FileManager/utils/fileUtils"; import { showToast } from "@/app/utils/toast"; interface FileManagerProps { @@ -150,20 +176,23 @@ export const FileManager = forwardRef( } }; - const loadDirectory = useCallback(async (path: string) => { - if (!sessionId) return; + const loadDirectory = useCallback( + async (path: string) => { + if (!sessionId) return; - try { - setIsLoading(true); - const response = await listSSHFiles(sessionId, path); - setFiles(response.files || []); - setCurrentPath(response.path || path); - } catch (error: any) { - showToast.error(error.message || "Failed to load directory"); - } finally { - setIsLoading(false); - } - }, [sessionId]); + try { + setIsLoading(true); + const response = await listSSHFiles(sessionId, path); + setFiles(response.files || []); + setCurrentPath(response.path || path); + } catch (error: any) { + showToast.error(error.message || "Failed to load directory"); + } finally { + setIsLoading(false); + } + }, + [sessionId], + ); // File operations const handleFilePress = async (file: FileItem) => { @@ -273,7 +302,12 @@ export const FileManager = forwardRef( try { setIsLoading(true); - await renameSSHItem(sessionId!, renameDialog.file.path, renameName, host.id); + await renameSSHItem( + sessionId!, + renameDialog.file.path, + renameName, + host.id, + ); showToast.success("Item renamed successfully"); setRenameDialog({ visible: false, file: null }); setRenameName(""); @@ -310,7 +344,12 @@ export const FileManager = forwardRef( if (clipboard.operation === "copy") { await copySSHItem(sessionId!, filePath, currentPath, host.id); } else { - await moveSSHItem(sessionId!, filePath, joinPath(currentPath, filePath.split("/").pop()!), host.id); + await moveSSHItem( + sessionId!, + filePath, + joinPath(currentPath, filePath.split("/").pop()!), + host.id, + ); } } showToast.success(`${clipboard.files.length} item(s) pasted`); @@ -324,7 +363,9 @@ export const FileManager = forwardRef( }; const handleDelete = async (file?: FileItem) => { - const filesToDelete = file ? [file] : files.filter((f) => selectedFiles.includes(f.path)); + const filesToDelete = file + ? [file] + : files.filter((f) => selectedFiles.includes(f.path)); Alert.alert( "Confirm Delete", @@ -342,7 +383,7 @@ export const FileManager = forwardRef( sessionId!, fileItem.path, fileItem.type === "directory", - host.id + host.id, ); } showToast.success(`${filesToDelete.length} item(s) deleted`); @@ -356,13 +397,13 @@ export const FileManager = forwardRef( } }, }, - ] + ], ); }; const handleSelectToggle = (path: string) => { setSelectedFiles((prev) => - prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path] + prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], ); }; @@ -444,7 +485,9 @@ export const FileManager = forwardRef( }} activeOpacity={0.7} > - Cancel + + Cancel + ( }} activeOpacity={0.7} > - Verify + + Verify +
@@ -473,9 +518,12 @@ export const FileManager = forwardRef( const toolbarPaddingVertical = isLandscape ? 8 : 12; const toolbarContentHeight = isLandscape ? 34 : 44; // Approximate content height const toolbarBorderHeight = 2; - const effectiveToolbarHeight = (selectionMode || clipboard.files.length > 0) - ? (toolbarPaddingVertical * 2) + toolbarContentHeight + toolbarBorderHeight - : 0; + const effectiveToolbarHeight = + selectionMode || clipboard.files.length > 0 + ? toolbarPaddingVertical * 2 + + toolbarContentHeight + + toolbarBorderHeight + : 0; return ( ( > - Create New {createDialog.type === "folder" ? "Folder" : "File"} + Create New{" "} + {createDialog.type === "folder" ? "Folder" : "File"} ( setCreateDialog({ visible: false, type: null }); setCreateName(""); }} - className="flex-1 bg-[#27272a] py-3" + className="flex-1 py-3" style={{ - borderWidth: 2, - borderColor: "#3f3f46", - borderRadius: 8, + backgroundColor: BACKGROUNDS.BUTTON, + borderWidth: BORDERS.MAJOR, + borderColor: BORDER_COLORS.BUTTON, + borderRadius: RADIUS.BUTTON, }} activeOpacity={0.7} > - Cancel + + Cancel + ( }} activeOpacity={0.7} > - Create + + Create + @@ -625,11 +681,12 @@ export const FileManager = forwardRef( > @@ -637,11 +694,12 @@ export const FileManager = forwardRef( Rename Item ( setRenameDialog({ visible: false, file: null }); setRenameName(""); }} - className="flex-1 bg-[#27272a] py-3" + className="flex-1 py-3" style={{ - borderWidth: 2, - borderColor: "#3f3f46", - borderRadius: 8, + backgroundColor: BACKGROUNDS.BUTTON, + borderWidth: BORDERS.MAJOR, + borderColor: BORDER_COLORS.BUTTON, + borderRadius: RADIUS.BUTTON, }} activeOpacity={0.7} > - Cancel + + Cancel + ( }} activeOpacity={0.7} > - Rename + + Rename + @@ -687,7 +750,9 @@ export const FileManager = forwardRef( {fileViewer.file && ( setFileViewer({ visible: false, file: null, content: "" })} + onClose={() => + setFileViewer({ visible: false, file: null, content: "" }) + } fileName={fileViewer.file.name} filePath={fileViewer.file.path} initialContent={fileViewer.content} @@ -696,7 +761,7 @@ export const FileManager = forwardRef( )} ); - } + }, ); FileManager.displayName = "FileManager"; diff --git a/app/Tabs/Sessions/FileManager/ContextMenu.tsx b/app/Tabs/Sessions/FileManager/ContextMenu.tsx index 0f27958..1cb2879 100644 --- a/app/Tabs/Sessions/FileManager/ContextMenu.tsx +++ b/app/Tabs/Sessions/FileManager/ContextMenu.tsx @@ -65,7 +65,7 @@ export function ContextMenu({ transparent animationType="fade" onRequestClose={onClose} - supportedOrientations={['portrait', 'landscape']} + supportedOrientations={["portrait", "landscape"]} > @@ -73,7 +73,10 @@ export function ContextMenu({ {/* Header */} - + {fileName} - {isSelected && } + {isSelected && ( + + )} )} @@ -78,14 +85,18 @@ export function FileItem({ ) : ( <> {size !== undefined && ( - {formatFileSize(size)} + + {formatFileSize(size)} + )} {modified && ( <> {size !== undefined && ( )} - {formatDate(modified)} + + {formatDate(modified)} + )} diff --git a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx index b29e262..bddd60e 100644 --- a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx +++ b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx @@ -9,8 +9,16 @@ import { MoreVertical, } from "lucide-react-native"; import { breadcrumbsFromPath, getBreadcrumbLabel } from "./utils/fileUtils"; -import { getResponsivePadding, getResponsiveFontSize } from "@/app/utils/responsive"; -import { BORDERS, BORDER_COLORS, BACKGROUNDS, RADIUS } from "@/app/constants/designTokens"; +import { + getResponsivePadding, + getResponsiveFontSize, +} from "@/app/utils/responsive"; +import { + BORDERS, + BORDER_COLORS, + BACKGROUNDS, + RADIUS, +} from "@/app/constants/designTokens"; interface FileManagerHeaderProps { currentPath: string; @@ -77,9 +85,18 @@ export function FileManagerHeader({ )} {breadcrumbs.map((path, index) => ( - + {index > 0 && breadcrumbs[index - 1] !== "/" && ( - + / )} @@ -89,14 +106,19 @@ export function FileManagerHeader({ paddingHorizontal: 8, paddingVertical: 4, borderRadius: RADIUS.SMALL, - backgroundColor: index === breadcrumbs.length - 1 ? BACKGROUNDS.BUTTON_ALT : "transparent", + backgroundColor: + index === breadcrumbs.length - 1 + ? BACKGROUNDS.BUTTON_ALT + : "transparent", }} activeOpacity={0.7} > @@ -109,7 +131,13 @@ export function FileManagerHeader({ {/* Action buttons */} - + {/* Selection count */} - + {selectedCount} selected - + {/* Copy */} {/* Clipboard info */} - + {clipboardCount} item{clipboardCount !== 1 ? "s" : ""}{" "} {clipboardOperation === "copy" ? "copied" : "cut"} - + {/* Paste */} @@ -133,7 +133,9 @@ export function FileViewer({ - + {fileName} - + {filePath} @@ -165,7 +173,12 @@ export function FileViewer({ className="p-2 bg-dark-bg-button rounded border border-dark-border" activeOpacity={0.7} disabled={isSaving} - style={{ width: 34, height: 34, alignItems: 'center', justifyContent: 'center' }} + style={{ + width: 34, + height: 34, + alignItems: "center", + justifyContent: "center", + }} > {isSaving ? ( diff --git a/app/Tabs/Sessions/FileManager/utils/fileUtils.ts b/app/Tabs/Sessions/FileManager/utils/fileUtils.ts index b5ca8dd..20f5072 100644 --- a/app/Tabs/Sessions/FileManager/utils/fileUtils.ts +++ b/app/Tabs/Sessions/FileManager/utils/fileUtils.ts @@ -39,10 +39,43 @@ export function joinPath(...parts: string[]): string { export function isTextFile(filename: string): boolean { const ext = getFileExtension(filename); const textExtensions = [ - "txt", "md", "json", "xml", "html", "css", "js", "ts", "tsx", "jsx", - "py", "java", "c", "cpp", "h", "hpp", "cs", "php", "rb", "go", "rs", - "sh", "bash", "zsh", "fish", "yml", "yaml", "toml", "ini", "cfg", "conf", - "log", "env", "gitignore", "dockerignore", "editorconfig", "prettierrc", + "txt", + "md", + "json", + "xml", + "html", + "css", + "js", + "ts", + "tsx", + "jsx", + "py", + "java", + "c", + "cpp", + "h", + "hpp", + "cs", + "php", + "rb", + "go", + "rs", + "sh", + "bash", + "zsh", + "fish", + "yml", + "yaml", + "toml", + "ini", + "cfg", + "conf", + "log", + "env", + "gitignore", + "dockerignore", + "editorconfig", + "prettierrc", ]; return textExtensions.includes(ext); } @@ -55,7 +88,16 @@ export function isArchiveFile(filename: string): boolean { export function isImageFile(filename: string): boolean { const ext = getFileExtension(filename); - const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "svg", "webp", "ico"]; + const imageExtensions = [ + "jpg", + "jpeg", + "png", + "gif", + "bmp", + "svg", + "webp", + "ico", + ]; return imageExtensions.includes(ext); } @@ -94,7 +136,7 @@ export function formatDate(dateString: string | undefined): string { export function sortFiles( files: any[], sortBy: "name" | "size" | "modified" = "name", - sortOrder: "asc" | "desc" = "asc" + sortOrder: "asc" | "desc" = "asc", ): any[] { const sorted = [...files].sort((a, b) => { // Always put directories first @@ -111,7 +153,9 @@ export function sortFiles( compareValue = (a.size || 0) - (b.size || 0); break; case "modified": - compareValue = new Date(a.modified || 0).getTime() - new Date(b.modified || 0).getTime(); + compareValue = + new Date(a.modified || 0).getTime() - + new Date(b.modified || 0).getTime(); break; } @@ -141,7 +185,8 @@ export function getFileIconColor(filename: string, type: string): string { if (["json", "xml"].includes(ext)) return "#F59E0B"; // amber // Config files - if (["yml", "yaml", "toml", "ini", "conf", "cfg"].includes(ext)) return "#8B5CF6"; // purple + if (["yml", "yaml", "toml", "ini", "conf", "cfg"].includes(ext)) + return "#8B5CF6"; // purple if (["env", "gitignore", "dockerignore"].includes(ext)) return "#6B7280"; // gray // Documents diff --git a/app/Tabs/Sessions/KeyboardBar.tsx b/app/Tabs/Sessions/KeyboardBar.tsx index d323b2a..9758a12 100644 --- a/app/Tabs/Sessions/KeyboardBar.tsx +++ b/app/Tabs/Sessions/KeyboardBar.tsx @@ -1,18 +1,16 @@ import React, { useState, useEffect } from "react"; -import { - View, - ScrollView, - Text, - Clipboard, - Platform, -} from "react-native"; +import { View, ScrollView, Text, Clipboard, Platform } from "react-native"; import { TerminalHandle } from "./Terminal"; import KeyboardKey from "./KeyboardKey"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; import { KeyConfig } from "@/types/keyboard"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { useOrientation } from "@/app/utils/orientation"; -import { BORDERS, BORDER_COLORS } from "@/app/constants/designTokens"; +import { + BORDERS, + BORDER_COLORS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; interface KeyboardBarProps { terminalRef: React.RefObject; @@ -128,10 +126,11 @@ export default function KeyboardBar({ return ( diff --git a/app/Tabs/Sessions/KeyboardKey.tsx b/app/Tabs/Sessions/KeyboardKey.tsx index e312ec8..95672c3 100644 --- a/app/Tabs/Sessions/KeyboardKey.tsx +++ b/app/Tabs/Sessions/KeyboardKey.tsx @@ -2,7 +2,11 @@ import React from "react"; import { TouchableOpacity, Text } from "react-native"; import * as Haptics from "expo-haptics"; import { KeySize } from "@/types/keyboard"; -import { BACKGROUNDS, BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; +import { + BACKGROUNDS, + BORDER_COLORS, + RADIUS, +} from "@/app/constants/designTokens"; interface KeyboardKeyProps { label: string; @@ -82,7 +86,10 @@ export default function KeyboardKey({ activeOpacity={0.7} delayLongPress={500} > - + {label} diff --git a/app/Tabs/Sessions/Navigation/TabBar.tsx b/app/Tabs/Sessions/Navigation/TabBar.tsx index 7e2846e..d56f937 100644 --- a/app/Tabs/Sessions/Navigation/TabBar.tsx +++ b/app/Tabs/Sessions/Navigation/TabBar.tsx @@ -21,7 +21,12 @@ import { useRouter } from "expo-router"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { useOrientation } from "@/app/utils/orientation"; import { getTabBarHeight, getButtonSize } from "@/app/utils/responsive"; -import { BORDERS, BORDER_COLORS, BACKGROUNDS, RADIUS } from "@/app/constants/designTokens"; +import { + BORDERS, + BORDER_COLORS, + BACKGROUNDS, + RADIUS, +} from "@/app/constants/designTokens"; interface TabBarProps { sessions: TerminalSession[]; @@ -80,28 +85,28 @@ export default function TabBar({ } return ( - <> + - router.navigate("/hosts" as any)} focusable={false} @@ -162,10 +167,14 @@ export default function TabBar({ className="flex-row items-center" style={{ borderWidth: BORDERS.STANDARD, - borderColor: isActive ? BORDER_COLORS.ACTIVE : BORDER_COLORS.BUTTON, + borderColor: isActive + ? BORDER_COLORS.ACTIVE + : BORDER_COLORS.BUTTON, backgroundColor: BACKGROUNDS.CARD, borderRadius: RADIUS.BUTTON, - shadowColor: isActive ? BORDER_COLORS.ACTIVE : "transparent", + shadowColor: isActive + ? BORDER_COLORS.ACTIVE + : "transparent", shadowOffset: { width: 0, height: 2 }, shadowOpacity: isActive ? 0.2 : 0, shadowRadius: 4, @@ -196,7 +205,9 @@ export default function TabBar({ width: isLandscape ? 32 : 36, height: buttonSize, borderLeftWidth: BORDERS.STANDARD, - borderLeftColor: isActive ? BORDER_COLORS.ACTIVE : BORDER_COLORS.BUTTON, + borderLeftColor: isActive + ? BORDER_COLORS.ACTIVE + : BORDER_COLORS.BUTTON, }} > )} - - - - - + ); } diff --git a/app/Tabs/Sessions/ServerStats.tsx b/app/Tabs/Sessions/ServerStats.tsx index be1dbc7..b4f2257 100644 --- a/app/Tabs/Sessions/ServerStats.tsx +++ b/app/Tabs/Sessions/ServerStats.tsx @@ -27,8 +27,17 @@ import { getServerMetricsById } from "../../main-axios"; import { showToast } from "../../utils/toast"; import type { ServerMetrics } from "../../../types/index"; import { useOrientation } from "@/app/utils/orientation"; -import { getResponsivePadding, getColumnCount, getTabBarHeight } from "@/app/utils/responsive"; -import { BACKGROUNDS, BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; +import { + getResponsivePadding, + getColumnCount, + getTabBarHeight, +} from "@/app/utils/responsive"; +import { + BACKGROUNDS, + BORDER_COLORS, + RADIUS, + TEXT_COLORS, +} from "@/app/constants/designTokens"; interface ServerStatsProps { hostConfig: { @@ -117,9 +126,8 @@ export const ServerStats = forwardRef( }; }, [isVisible, fetchMetrics]); - const cardWidth = isLandscape && columnCount > 1 - ? `${(100 / columnCount) - 1}%` - : "100%"; + const cardWidth = + isLandscape && columnCount > 1 ? `${100 / columnCount - 1}%` : "100%"; const formatUptime = (seconds: number | null): string => { if (seconds === null || seconds === undefined) return "N/A"; @@ -156,21 +164,32 @@ export const ServerStats = forwardRef( width: cardWidth, }} > + + {icon} + + {title} + + - - {icon} - - {title} - - - - + {value} - - {subtitle} - + {subtitle} ); @@ -184,7 +203,7 @@ export const ServerStats = forwardRef( ( marginTop: 24, }} > - + Retry @@ -277,7 +298,9 @@ export const ServerStats = forwardRef( > {/* Header */} - + {hostConfig.name} @@ -288,7 +311,8 @@ export const ServerStats = forwardRef( {/* Grid Container */} 1 ? "row" : "column", + flexDirection: + isLandscape && columnCount > 1 ? "row" : "column", flexWrap: "wrap", gap: 12, }} @@ -319,34 +343,71 @@ export const ServerStats = forwardRef( width: cardWidth, }} > - + - + Load Average - + {metrics.cpu.load[0].toFixed(2)} - + 1 min - + {metrics.cpu.load[1].toFixed(2)} - + 5 min - + {metrics.cpu.load[2].toFixed(2)} - + 15 min diff --git a/app/Tabs/Sessions/Sessions.tsx b/app/Tabs/Sessions/Sessions.tsx index 3a54064..9582aef 100644 --- a/app/Tabs/Sessions/Sessions.tsx +++ b/app/Tabs/Sessions/Sessions.tsx @@ -21,14 +21,21 @@ import { useRouter } from "expo-router"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { Terminal, TerminalHandle } from "@/app/Tabs/Sessions/Terminal"; -import { ServerStats, ServerStatsHandle } from "@/app/Tabs/Sessions/ServerStats"; -import { FileManager, FileManagerHandle } from "@/app/Tabs/Sessions/FileManager"; +import { + ServerStats, + ServerStatsHandle, +} from "@/app/Tabs/Sessions/ServerStats"; +import { + FileManager, + FileManagerHandle, +} from "@/app/Tabs/Sessions/FileManager"; import TabBar from "@/app/Tabs/Sessions/Navigation/TabBar"; import BottomToolbar from "@/app/Tabs/Sessions/BottomToolbar"; import KeyboardBar from "@/app/Tabs/Sessions/KeyboardBar"; import { ArrowLeft } from "lucide-react-native"; import { useOrientation } from "@/app/utils/orientation"; import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; +import { BACKGROUNDS, BORDER_COLORS } from "@/app/constants/designTokens"; export default function Sessions() { const insets = useSafeAreaInsets(); @@ -54,9 +61,9 @@ export default function Sessions() { const statsRefs = useRef>>( {}, ); - const fileManagerRefs = useRef>>( - {}, - ); + const fileManagerRefs = useRef< + Record> + >({}); const [activeModifiers, setActiveModifiers] = useState({ ctrl: false, alt: false, @@ -75,43 +82,84 @@ export default function Sessions() { ? Math.min(keyboardHeight, maxKeyboardHeight) : keyboardHeight; - // BottomToolbar height includes tab bar + content + safe area insets - const TAB_BAR_HEIGHT = 36; - const bottomToolbarHeight = isCustomKeyboardVisible - ? TAB_BAR_HEIGHT + effectiveKeyboardHeight + insets.bottom - : 0; + // Component height constants + const SESSION_TAB_BAR_HEIGHT = getTabBarHeight(isLandscape); // 50-60px + const CUSTOM_KEYBOARD_TAB_HEIGHT = 36; + const KEYBOARD_BAR_HEIGHT = 52; // Normal keyboard bar height + const KEYBOARD_BAR_HEIGHT_EXTENDED = 66; // When keyboard intentionally hidden + + // Helper function to calculate TabBar bottom position + const getTabBarBottomPosition = () => { + const position = (() => { + if (activeSession?.type !== "terminal") { + return 0; // Non-terminal sessions: sits at bottom + } + + // Terminal session positioning - TabBar sits above KeyboardBar and any keyboards + if (keyboardIntentionallyHiddenRef.current) { + return KEYBOARD_BAR_HEIGHT_EXTENDED; // Above extended keyboard bar + } + + if (isCustomKeyboardVisible) { + // Above: KeyboardBar + BottomToolbar (which includes tabs + content) + // BottomToolbar height = CUSTOM_KEYBOARD_TAB_HEIGHT + effectiveKeyboardHeight + return KEYBOARD_BAR_HEIGHT + CUSTOM_KEYBOARD_TAB_HEIGHT + effectiveKeyboardHeight; + } - // Calculate bottom margins for content - const getBottomMargin = (sessionType: "terminal" | "stats" | "filemanager" = "terminal") => { - const sessionTabBarHeight = getTabBarHeight(isLandscape); + if (isKeyboardVisible && currentKeyboardHeight > 0) { + // Above: KeyboardBar + system keyboard + return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; + } + + return KEYBOARD_BAR_HEIGHT; // Just above keyboard bar (no keyboard showing) + })(); - // For non-terminal sessions, use simple tab bar height + safe area + console.log('[TabBar Position]', { + activeSessionType: activeSession?.type, + isCustomKeyboardVisible, + keyboardIntentionallyHidden: keyboardIntentionallyHiddenRef.current, + isKeyboardVisible, + currentKeyboardHeight, + KEYBOARD_BAR_HEIGHT, + CUSTOM_KEYBOARD_TAB_HEIGHT, + effectiveKeyboardHeight, + calculatedPosition: position, + }); + + return position; + }; + + // Calculate bottom margins for content (terminal content area) + const getBottomMargin = ( + sessionType: "terminal" | "stats" | "filemanager" = "terminal", + ) => { + // For non-terminal sessions, just the session tab bar if (sessionType !== "terminal") { - return sessionTabBarHeight + insets.bottom; + return SESSION_TAB_BAR_HEIGHT + insets.bottom; } - // Terminal-specific logic with keyboard handling - const keyboardBarHeight = 50; - const baseMargin = sessionTabBarHeight + keyboardBarHeight; + // Terminal sessions need to account for: SessionTabBar + KeyboardBar + (optional keyboard) + let margin = SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT; if (keyboardIntentionallyHiddenRef.current) { - return sessionTabBarHeight + 66; // 66 is the larger keyboard bar height when hidden + // No keyboard, but extended keyboard bar + return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT_EXTENDED; } if (isCustomKeyboardVisible) { - // Custom keyboard: session tab bar + keyboard bar + TAB_BAR_HEIGHT + keyboard content - return sessionTabBarHeight + keyboardBarHeight + TAB_BAR_HEIGHT + effectiveKeyboardHeight; + // Custom keyboard showing: add tab bar + keyboard content + margin += CUSTOM_KEYBOARD_TAB_HEIGHT + effectiveKeyboardHeight; + return margin; } if (isKeyboardVisible && currentKeyboardHeight > 0) { - return currentKeyboardHeight + baseMargin; - } - - if (effectiveKeyboardHeight > 0) { - return effectiveKeyboardHeight + baseMargin; + // System keyboard showing + margin += currentKeyboardHeight; + return margin; } - return baseMargin; + // No keyboard showing, just bars + return margin; }; useEffect(() => { @@ -303,7 +351,7 @@ export default function Sessions() { ); const handleTabPress = (sessionId: string) => { - const session = sessions.find(s => s.id === sessionId); + const session = sessions.find((s) => s.id === sessionId); setKeyboardIntentionallyHidden(false); setActiveSession(sessionId); setTimeout(() => { @@ -316,7 +364,11 @@ export default function Sessions() { const handleTabClose = (sessionId: string) => { removeSession(sessionId); setTimeout(() => { - if (activeSession?.type === "terminal" && !isCustomKeyboardVisible && sessions.length > 1) { + if ( + activeSession?.type === "terminal" && + !isCustomKeyboardVisible && + sessions.length > 1 + ) { hiddenInputRef.current?.focus(); } }, 100); @@ -328,13 +380,19 @@ export default function Sessions() { const handleToggleKeyboard = () => { if (isCustomKeyboardVisible) { + // Closing custom keyboard - reopen system keyboard + toggleCustomKeyboard(); + setTimeout(() => { + setKeyboardIntentionallyHidden(false); + hiddenInputRef.current?.focus(); + }, 150); + } else { + // Opening custom keyboard - close system keyboard + setKeyboardIntentionallyHidden(true); Keyboard.dismiss(); setTimeout(() => { toggleCustomKeyboard(); }, 100); - } else { - setKeyboardIntentionallyHidden(false); - toggleCustomKeyboard(); } }; @@ -434,12 +492,12 @@ export default function Sessions() { > 0 ? currentKeyboardHeight : 0, left: 0, right: 0, - height: keyboardIntentionallyHiddenRef.current ? 66 : 52, + height: keyboardIntentionallyHiddenRef.current ? KEYBOARD_BAR_HEIGHT_EXTENDED : KEYBOARD_BAR_HEIGHT, zIndex: 1003, overflow: "visible", }} @@ -537,32 +595,27 @@ export default function Sessions() { )} - {sessions.length > 0 && (activeSession?.type === "stats" || activeSession?.type === "filemanager") && isCustomKeyboardVisible && ( - - )} + {sessions.length > 0 && + (activeSession?.type === "stats" || + activeSession?.type === "filemanager") && + isCustomKeyboardVisible && ( + + )} 0 - ? currentKeyboardHeight + 50 - : 50 - : 32, + bottom: getTabBarBottomPosition(), left: 0, right: 0, height: 60, @@ -585,137 +638,149 @@ export default function Sessions() { /> - {sessions.length > 0 && isCustomKeyboardVisible && activeSession?.type === "terminal" && ( - - 0 && + isCustomKeyboardVisible && + activeSession?.type === "terminal" && ( + + () + } + isVisible={isCustomKeyboardVisible} + keyboardHeight={effectiveKeyboardHeight} + isKeyboardIntentionallyHidden={ + keyboardIntentionallyHiddenRef.current + } + /> + + )} + + {sessions.length > 0 && + !isCustomKeyboardVisible && + activeSession?.type === "terminal" && ( + 0 ? currentKeyboardHeight : 0, + left: 0, + width: 1, + height: 1, + opacity: 0, + color: "transparent", + backgroundColor: "transparent", + zIndex: 1001, + }} + pointerEvents="none" + autoFocus={false} + showSoftInputOnFocus={true} + keyboardType={keyboardType} + returnKeyType="default" + blurOnSubmit={false} + editable={true} + autoCorrect={false} + autoCapitalize="none" + spellCheck={false} + textContentType="none" + caretHidden + contextMenuHidden + underlineColorAndroid="transparent" + multiline + onChangeText={() => { + // Do nothing - we handle input in onKeyPress only + }} + onKeyPress={({ nativeEvent }) => { + const key = nativeEvent.key; + const activeRef = activeSessionId ? terminalRefs.current[activeSessionId] - : React.createRef() - } - isVisible={isCustomKeyboardVisible} - keyboardHeight={effectiveKeyboardHeight} - isKeyboardIntentionallyHidden={ - keyboardIntentionallyHiddenRef.current - } - currentHostId={ - activeSession ? parseInt(activeSession.host.id.toString()) : undefined - } - /> - - )} + : null; + + if (!activeRef?.current) return; + + let finalKey = key; + + // Handle modifiers + if (activeModifiers.ctrl) { + switch (key.toLowerCase()) { + case "c": + finalKey = "\x03"; + break; + case "d": + finalKey = "\x04"; + break; + case "z": + finalKey = "\x1a"; + break; + case "l": + finalKey = "\x0c"; + break; + case "a": + finalKey = "\x01"; + break; + case "e": + finalKey = "\x05"; + break; + case "k": + finalKey = "\x0b"; + break; + case "u": + finalKey = "\x15"; + break; + case "w": + finalKey = "\x17"; + break; + default: + if (key.length === 1) { + finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); + } + } + } else if (activeModifiers.alt) { + finalKey = `\x1b${key}`; + } - {sessions.length > 0 && !isCustomKeyboardVisible && activeSession?.type === "terminal" && ( - 0 ? currentKeyboardHeight : 0, - left: 0, - width: 1, - height: 1, - opacity: 0, - color: "transparent", - backgroundColor: "transparent", - zIndex: 1001, - }} - pointerEvents="none" - autoFocus={false} - showSoftInputOnFocus={true} - keyboardType={keyboardType} - returnKeyType="default" - blurOnSubmit={false} - editable={true} - autoCorrect={false} - autoCapitalize="none" - spellCheck={false} - textContentType="none" - caretHidden - contextMenuHidden - underlineColorAndroid="transparent" - multiline - onChangeText={() => { - // Do nothing - we handle input in onKeyPress only - }} - onKeyPress={({ nativeEvent }) => { - const key = nativeEvent.key; - const activeRef = activeSessionId - ? terminalRefs.current[activeSessionId] - : null; - - if (!activeRef?.current) return; - - let finalKey = key; - - // Handle modifiers - if (activeModifiers.ctrl) { - switch (key.toLowerCase()) { - case "c": - finalKey = "\x03"; - break; - case "d": - finalKey = "\x04"; - break; - case "z": - finalKey = "\x1a"; - break; - case "l": - finalKey = "\x0c"; - break; - case "a": - finalKey = "\x01"; - break; - case "e": - finalKey = "\x05"; - break; - case "k": - finalKey = "\x0b"; - break; - case "u": - finalKey = "\x15"; - break; - case "w": - finalKey = "\x17"; - break; - default: - if (key.length === 1) { - finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); + // Send the appropriate key + if (key === "Enter") { + activeRef.current.sendInput("\r"); + } else if (key === "Backspace") { + activeRef.current.sendInput("\b"); + } else if (key.length === 1) { + activeRef.current.sendInput(finalKey); + } + }} + onFocus={() => { + setKeyboardIntentionallyHidden(false); + }} + onBlur={() => { + // Use a longer delay to avoid flicker from accidental touches + // but still maintain focus for typing + if ( + !keyboardIntentionallyHiddenRef.current && + !isCustomKeyboardVisible && + activeSession?.type === "terminal" + ) { + setTimeout(() => { + if ( + !keyboardIntentionallyHiddenRef.current && + !isCustomKeyboardVisible && + activeSession?.type === "terminal" + ) { + hiddenInputRef.current?.focus(); } + }, 200); // 200ms delay to allow intentional taps to complete } - } else if (activeModifiers.alt) { - finalKey = `\x1b${key}`; - } - - // Send the appropriate key - if (key === "Enter") { - activeRef.current.sendInput("\r"); - } else if (key === "Backspace") { - activeRef.current.sendInput("\b"); - } else if (key.length === 1) { - activeRef.current.sendInput(finalKey); - } - }} - onFocus={() => { - setKeyboardIntentionallyHidden(false); - }} - onBlur={() => { - // Immediately refocus if keyboard wasn't intentionally hidden - if (!keyboardIntentionallyHiddenRef.current && !isCustomKeyboardVisible && activeSession?.type === "terminal") { - setTimeout(() => { - hiddenInputRef.current?.focus(); - }, 0); - } - }} - /> - )} + }} + /> + )} ); } diff --git a/app/Tabs/Sessions/SnippetsBar.tsx b/app/Tabs/Sessions/SnippetsBar.tsx index 2996112..03a305e 100644 --- a/app/Tabs/Sessions/SnippetsBar.tsx +++ b/app/Tabs/Sessions/SnippetsBar.tsx @@ -41,7 +41,7 @@ export default function SnippetsBar({ const [snippets, setSnippets] = useState([]); const [folders, setFolders] = useState([]); const [collapsedFolders, setCollapsedFolders] = useState>( - new Set() + new Set(), ); const [loading, setLoading] = useState(true); @@ -55,20 +55,24 @@ export default function SnippetsBar({ try { setLoading(true); const [snippetsData, foldersData] = await Promise.all([ - getSnippets(), - getSnippetFolders(), + getSnippets().catch(() => []), + getSnippetFolders().catch(() => []), ]); setSnippets( - snippetsData.sort((a: Snippet, b: Snippet) => a.sortOrder - b.sortOrder) + (snippetsData || []).sort( + (a: Snippet, b: Snippet) => a.sortOrder - b.sortOrder, + ), ); setFolders( - foldersData.sort( - (a: SnippetFolder, b: SnippetFolder) => a.sortOrder - b.sortOrder - ) + (foldersData || []).sort( + (a: SnippetFolder, b: SnippetFolder) => a.sortOrder - b.sortOrder, + ), ); } catch (error) { - showToast.error("Failed to load snippets"); + console.error("Failed to load snippets:", error); + setSnippets([]); + setFolders([]); } finally { setLoading(false); } @@ -144,7 +148,10 @@ export default function SnippetsBar({ }} onPress={() => executeSnippet(snippet)} > - + {snippet.name} @@ -171,10 +178,15 @@ export default function SnippetsBar({ {folder.icon && ( {folder.icon} )} - + {folder.name} - ({folderSnippets.length}) + + ({folderSnippets.length}) + {isCollapsed ? "▶" : "▼"} @@ -193,7 +205,10 @@ export default function SnippetsBar({ }} onPress={() => executeSnippet(snippet)} > - + {snippet.name} @@ -204,7 +219,9 @@ export default function SnippetsBar({ {snippets.length === 0 && ( - No snippets yet + + No snippets yet + Create snippets in Settings diff --git a/app/Tabs/Sessions/Terminal.tsx b/app/Tabs/Sessions/Terminal.tsx index ce684f0..b98b598 100644 --- a/app/Tabs/Sessions/Terminal.tsx +++ b/app/Tabs/Sessions/Terminal.tsx @@ -20,10 +20,10 @@ import { getCurrentServerUrl, getCookie, logActivity, - saveCommandToHistory, } from "../../main-axios"; import { showToast } from "../../utils/toast"; import { useTerminalCustomization } from "../../contexts/TerminalCustomizationContext"; +import { BACKGROUNDS, BORDER_COLORS } from "../../constants/designTokens"; interface TerminalProps { hostConfig: { @@ -68,8 +68,6 @@ const TerminalComponent = forwardRef( const connectionTimeoutRef = useRef | null>( null, ); - const currentCommandRef = useRef(""); - const commandHistoryRef = useRef([]); useEffect(() => { const subscription = Dimensions.addEventListener( @@ -327,39 +325,6 @@ const TerminalComponent = forwardRef( let shouldNotReconnect = false; let hasNotifiedFailure = false; - // Command history tracking - let currentCommand = ''; - let commandHistory = []; - - function trackInput(data) { - if (data === '\\r' || data === '\\n') { - // Enter key pressed - command executed - const cmd = currentCommand.trim(); - if (cmd && cmd.length > 0) { - // Notify React Native about the command - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'commandExecuted', - data: { command: cmd } - })); - } - } - currentCommand = ''; - } else if (data === '\\x7f' || data === '\\b') { - // Backspace - currentCommand = currentCommand.slice(0, -1); - } else if (data === '\\x03') { - // Ctrl+C - clear current command - currentCommand = ''; - } else if (data === '\\x15') { - // Ctrl+U - clear line - currentCommand = ''; - } else if (data.length === 1 && data.charCodeAt(0) >= 32) { - // Printable character - currentCommand += data; - } - } - function notifyConnectionState(state, data = {}) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ @@ -397,7 +362,6 @@ const TerminalComponent = forwardRef( window.nativeInput = function(data) { try { - trackInput(data); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data: data })); } else { @@ -656,23 +620,6 @@ const TerminalComponent = forwardRef( ); break; - case "commandExecuted": - // Save command to history - if (message.data.command && hostConfig.id) { - const cmd = message.data.command; - currentCommandRef.current = ""; - - // Don't save duplicate commands or very short commands - if (cmd.length > 1 && !commandHistoryRef.current.includes(cmd)) { - commandHistoryRef.current = [cmd, ...commandHistoryRef.current]; - - // Save to backend asynchronously - saveCommandToHistory(hostConfig.id, cmd).catch((error) => { - console.error("Failed to save command to history:", error); - }); - } - } - break; } } catch (error) {} }, @@ -767,44 +714,45 @@ const TerminalComponent = forwardRef( {/* Note: Hidden TextInput removed - keyboard handled by Sessions.tsx */} {}} - onMessage={handleWebViewMessage} - onError={(syntheticEvent) => { - const { nativeEvent } = syntheticEvent; - handleConnectionFailure( - `WebView error: ${nativeEvent.description}`, - ); - }} - onHttpError={(syntheticEvent) => { - const { nativeEvent } = syntheticEvent; - handleConnectionFailure( - `WebView HTTP error: ${nativeEvent.statusCode}`, - ); - }} - scrollEnabled={true} - bounces={false} - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - nestedScrollEnabled={false} - /> + key={`terminal-${hostConfig.id}-${webViewKey}`} + ref={webViewRef} + source={{ html: htmlContent }} + style={{ + flex: 1, + width: "100%", + height: "100%", + backgroundColor: "#09090b", + opacity: showConnectingOverlay || isRetrying ? 0 : 1, + }} + javaScriptEnabled={true} + domStorageEnabled={true} + startInLoadingState={false} + scalesPageToFit={false} + allowsInlineMediaPlayback={true} + mediaPlaybackRequiresUserAction={false} + keyboardDisplayRequiresUserAction={false} + hideKeyboardAccessoryView={true} + onScroll={() => {}} + onMessage={handleWebViewMessage} + onError={(syntheticEvent) => { + const { nativeEvent } = syntheticEvent; + handleConnectionFailure( + `WebView error: ${nativeEvent.description}`, + ); + }} + onHttpError={(syntheticEvent) => { + const { nativeEvent } = syntheticEvent; + handleConnectionFailure( + `WebView HTTP error: ${nativeEvent.statusCode}`, + ); + }} + scrollEnabled={true} + bounces={false} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + nestedScrollEnabled={false} + /> + {(showConnectingOverlay || isRetrying) && ( ( bottom: 0, justifyContent: "center", alignItems: "center", - backgroundColor: "#09090b", + backgroundColor: BACKGROUNDS.DARKEST, padding: 20, }} > @@ -856,13 +804,13 @@ const TerminalComponent = forwardRef( {retryCount > 0 && ( string; + addSession: ( + host: SSHHost, + type?: "terminal" | "stats" | "filemanager", + ) => string; removeSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void; clearAllSessions: () => void; - navigateToSessions: (host?: SSHHost, type?: "terminal" | "stats" | "filemanager") => void; + navigateToSessions: ( + host?: SSHHost, + type?: "terminal" | "stats" | "filemanager", + ) => void; isCustomKeyboardVisible: boolean; toggleCustomKeyboard: () => void; lastKeyboardHeight: number; @@ -65,41 +71,48 @@ export const TerminalSessionsProvider: React.FC< const keyboardIntentionallyHiddenRef = useRef(false); const [, forceUpdate] = useState({}); - const addSession = useCallback((host: SSHHost, type: "terminal" | "stats" | "filemanager" = "terminal"): string => { - setSessions((prev) => { - const existingSessions = prev.filter( - (session) => session.host.id === host.id && session.type === type, - ); - - const typeLabel = type === "stats" ? "Stats" : type === "filemanager" ? "Files" : ""; - let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; - if (existingSessions.length > 0) { - title = typeLabel - ? `${host.name} - ${typeLabel} (${existingSessions.length + 1})` - : `${host.name} (${existingSessions.length + 1})`; - } + const addSession = useCallback( + ( + host: SSHHost, + type: "terminal" | "stats" | "filemanager" = "terminal", + ): string => { + setSessions((prev) => { + const existingSessions = prev.filter( + (session) => session.host.id === host.id && session.type === type, + ); - const sessionId = `${host.id}-${type}-${Date.now()}`; - const newSession: TerminalSession = { - id: sessionId, - host, - title, - isActive: true, - createdAt: new Date(), - type, - }; - - const updatedSessions = prev.map((session) => ({ - ...session, - isActive: false, - })); + const typeLabel = + type === "stats" ? "Stats" : type === "filemanager" ? "Files" : ""; + let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; + if (existingSessions.length > 0) { + title = typeLabel + ? `${host.name} - ${typeLabel} (${existingSessions.length + 1})` + : `${host.name} (${existingSessions.length + 1})`; + } - setActiveSessionId(sessionId); - return [...updatedSessions, newSession]; - }); + const sessionId = `${host.id}-${type}-${Date.now()}`; + const newSession: TerminalSession = { + id: sessionId, + host, + title, + isActive: true, + createdAt: new Date(), + type, + }; - return ""; - }, []); + const updatedSessions = prev.map((session) => ({ + ...session, + isActive: false, + })); + + setActiveSessionId(sessionId); + return [...updatedSessions, newSession]; + }); + + return ""; + }, + [], + ); const removeSession = useCallback( (sessionId: string) => { @@ -116,7 +129,8 @@ export const TerminalSessionsProvider: React.FC< const hostId = sessionToRemove.host.id; const sessionType = sessionToRemove.type; const sameHostSessions = updatedSessions.filter( - (session) => session.host.id === hostId && session.type === sessionType, + (session) => + session.host.id === hostId && session.type === sessionType, ); if (sameHostSessions.length > 0) { @@ -129,14 +143,18 @@ export const TerminalSessionsProvider: React.FC< (s) => s.id === session.id, ); if (sessionIndex !== -1) { - const typeLabel = session.type === "stats" ? "Stats" : session.type === "filemanager" ? "Files" : ""; - const baseName = typeLabel ? `${session.host.name} - ${typeLabel}` : session.host.name; + const typeLabel = + session.type === "stats" + ? "Stats" + : session.type === "filemanager" + ? "Files" + : ""; + const baseName = typeLabel + ? `${session.host.name} - ${typeLabel}` + : session.host.name; updatedSessions[sessionIndex] = { ...session, - title: - index === 0 - ? baseName - : `${baseName} (${index + 1})`, + title: index === 0 ? baseName : `${baseName} (${index + 1})`, }; } }); @@ -163,17 +181,27 @@ export const TerminalSessionsProvider: React.FC< ); const setActiveSession = useCallback((sessionId: string) => { - setSessions((prev) => - prev.map((session) => ({ + setSessions((prev) => { + const newSession = prev.find(s => s.id === sessionId); + + // Auto-close custom keyboard when switching to non-terminal sessions + if (newSession?.type !== 'terminal' && isCustomKeyboardVisible) { + setIsCustomKeyboardVisible(false); + } + + return prev.map((session) => ({ ...session, isActive: session.id === sessionId, - })), - ); + })); + }); setActiveSessionId(sessionId); - }, []); + }, [isCustomKeyboardVisible]); const navigateToSessions = useCallback( - (host?: SSHHost, type: "terminal" | "stats" | "filemanager" = "terminal") => { + ( + host?: SSHHost, + type: "terminal" | "stats" | "filemanager" = "terminal", + ) => { if (host) { addSession(host, type); } diff --git a/app/main-axios.ts b/app/main-axios.ts index 1daca79..cf2fd87 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -887,9 +887,7 @@ export async function exportSSHHostWithCredentials( // SSH AUTOSTART MANAGEMENT // ============================================================================ -export async function enableAutoStart( - sshConfigId: number, -): Promise { +export async function enableAutoStart(sshConfigId: number): Promise { try { const response = await sshHostApi.post("/autostart/enable", { sshConfigId, @@ -900,9 +898,7 @@ export async function enableAutoStart( } } -export async function disableAutoStart( - sshConfigId: number, -): Promise { +export async function disableAutoStart(sshConfigId: number): Promise { try { const response = await sshHostApi.delete("/autostart/disable", { data: { sshConfigId }, @@ -914,13 +910,13 @@ export async function disableAutoStart( } export async function getAutoStartStatus(): Promise<{ - autostart_configs: Array<{ + autostart_configs: { sshConfigId: number; host: string; port: number; username: string; authType: string; - }>; + }[]; total_count: number; }> { try { @@ -2114,7 +2110,7 @@ export async function getUserList(): Promise<{ users: UserInfo[] }> { } export async function getSessions(): Promise<{ - sessions: Array<{ + sessions: { id: string; userId: string; username?: string; @@ -2125,7 +2121,7 @@ export async function getSessions(): Promise<{ lastActiveAt: string; jwtToken: string; isRevoked?: boolean; - }>; + }[]; }> { try { const response = await authApi.get("/users/sessions"); @@ -2980,7 +2976,7 @@ export async function executeSnippet( } export async function reorderSnippets( - snippets: Array<{ id: number; order: number; folder?: string }>, + snippets: { id: number; order: number; folder?: string }[], ): Promise<{ success: boolean; updated: number }> { try { const response = await authApi.put("/snippets/reorder", { snippets }); @@ -3132,71 +3128,6 @@ export async function resetRecentActivity(): Promise<{ message: string }> { } } -// ============================================================================ -// COMMAND HISTORY API -// ============================================================================ - -export async function saveCommandToHistory( - hostId: number, - command: string, -): Promise<{ id: number; command: string; executedAt: string }> { - try { - const response = await authApi.post("/terminal/command_history", { - hostId, - command, - }); - return response.data; - } catch (error) { - handleApiError(error, "save command to history"); - throw error; - } -} - -export async function getCommandHistory( - hostId: number, - limit: number = 100, -): Promise { - try { - const response = await authApi.get(`/terminal/command_history/${hostId}`, { - params: { limit }, - }); - return response.data; - } catch (error) { - handleApiError(error, "fetch command history"); - throw error; - } -} - -export async function deleteCommandFromHistory( - hostId: number, - command: string, -): Promise<{ success: boolean }> { - try { - const response = await authApi.post("/terminal/command_history/delete", { - hostId, - command, - }); - return response.data; - } catch (error) { - handleApiError(error, "delete command from history"); - throw error; - } -} - -export async function clearCommandHistory( - hostId: number, -): Promise<{ success: boolean }> { - try { - const response = await authApi.delete( - `/terminal/command_history/${hostId}`, - ); - return response.data; - } catch (error) { - handleApiError(error, "clear command history"); - throw error; - } -} - // ============================================================================ // OIDC ACCOUNT LINKING // ============================================================================ diff --git a/app/utils/orientation.ts b/app/utils/orientation.ts index 13c2cb9..a728583 100644 --- a/app/utils/orientation.ts +++ b/app/utils/orientation.ts @@ -1,6 +1,6 @@ -import { useWindowDimensions } from 'react-native'; +import { useWindowDimensions } from "react-native"; -export type Orientation = 'portrait' | 'landscape'; +export type Orientation = "portrait" | "landscape"; /** * Hook to get current orientation and dimensions @@ -8,7 +8,7 @@ export type Orientation = 'portrait' | 'landscape'; export function useOrientation() { const { width, height } = useWindowDimensions(); const isLandscape = width > height; - const orientation: Orientation = isLandscape ? 'landscape' : 'portrait'; + const orientation: Orientation = isLandscape ? "landscape" : "portrait"; return { width, @@ -25,7 +25,7 @@ export function useOrientation() { export function getResponsiveValue( portraitValue: T, landscapeValue: T, - isLandscape: boolean + isLandscape: boolean, ): T { return isLandscape ? landscapeValue : portraitValue; } diff --git a/app/utils/responsive.ts b/app/utils/responsive.ts index a79eb6a..f82e004 100644 --- a/app/utils/responsive.ts +++ b/app/utils/responsive.ts @@ -5,7 +5,11 @@ /** * Get number of columns based on screen width and orientation */ -export function getColumnCount(width: number, isLandscape: boolean, itemMinWidth: number = 300): number { +export function getColumnCount( + width: number, + isLandscape: boolean, + itemMinWidth: number = 300, +): number { if (!isLandscape) return 1; const columns = Math.floor(width / itemMinWidth); @@ -15,7 +19,11 @@ export function getColumnCount(width: number, isLandscape: boolean, itemMinWidth /** * Calculate grid item width based on column count */ -export function getGridItemWidth(containerWidth: number, columns: number, gap: number = 16): number { +export function getGridItemWidth( + containerWidth: number, + columns: number, + gap: number = 16, +): number { const totalGap = gap * (columns - 1); return (containerWidth - totalGap) / columns; } @@ -23,21 +31,30 @@ export function getGridItemWidth(containerWidth: number, columns: number, gap: n /** * Get responsive padding */ -export function getResponsivePadding(isLandscape: boolean, portraitPadding: number = 24): number { +export function getResponsivePadding( + isLandscape: boolean, + portraitPadding: number = 24, +): number { return isLandscape ? portraitPadding * 0.67 : portraitPadding; // Reduce padding by 33% in landscape } /** * Get responsive font size */ -export function getResponsiveFontSize(isLandscape: boolean, baseFontSize: number): number { +export function getResponsiveFontSize( + isLandscape: boolean, + baseFontSize: number, +): number { return isLandscape ? baseFontSize * 0.9 : baseFontSize; // Slightly smaller in landscape } /** * Get max keyboard height for landscape mode */ -export function getMaxKeyboardHeight(screenHeight: number, isLandscape: boolean): number { +export function getMaxKeyboardHeight( + screenHeight: number, + isLandscape: boolean, +): number { if (!isLandscape) return screenHeight; // No limit in portrait return screenHeight * 0.4; // 40% max in landscape } @@ -52,6 +69,9 @@ export function getTabBarHeight(isLandscape: boolean): number { /** * Get responsive button size */ -export function getButtonSize(isLandscape: boolean, portraitSize: number = 44): number { +export function getButtonSize( + isLandscape: boolean, + portraitSize: number = 44, +): number { return isLandscape ? portraitSize * 0.82 : portraitSize; // ~36px in landscape vs 44px portrait } From 3691c509d1bc59b8d50c8b6eee143d70dd282c80 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 7 Dec 2025 15:19:40 -0600 Subject: [PATCH 06/27] feat: Fix tab bar height issues, and bein fixing terminal kb dismissal --- .../FileManager/FileManagerToolbar.tsx | 2 +- app/Tabs/Sessions/KeyboardBar.tsx | 3 --- app/Tabs/Sessions/Navigation/TabBar.tsx | 7 +++++- app/Tabs/Sessions/Sessions.tsx | 22 ++++++++----------- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx index f61c4d6..087d938 100644 --- a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx +++ b/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx @@ -47,7 +47,7 @@ export function FileManagerToolbar({ const iconSize = isLandscape ? 18 : 20; const buttonPadding = isLandscape ? 6 : 8; // Position above tab bar: in portrait we need more space, in landscape it's closer - const bottomPosition = isLandscape ? bottomInset : 0; + const bottomPosition = isLandscape ? bottomInset - 10 : 0; return ( diff --git a/app/Tabs/Sessions/Navigation/TabBar.tsx b/app/Tabs/Sessions/Navigation/TabBar.tsx index d56f937..4ebc447 100644 --- a/app/Tabs/Sessions/Navigation/TabBar.tsx +++ b/app/Tabs/Sessions/Navigation/TabBar.tsx @@ -67,6 +67,7 @@ export default function TabBar({ // Add bottom padding for non-terminal sessions (when tab bar is at the bottom) const needsBottomPadding = activeSessionType !== "terminal"; + const needsExtraMinHeight = activeSessionType !== "terminal"; const handleToggleSystemKeyboard = () => { if (keyboardIntentionallyHiddenRef.current) { @@ -93,9 +94,13 @@ export default function TabBar({ borderBottomWidth: activeSessionType === "terminal" ? BORDERS.STANDARD : 0, borderBottomColor: BORDER_COLORS.PRIMARY, - minHeight: tabBarHeight + (needsBottomPadding ? insets.bottom : 0), + minHeight: + tabBarHeight + + (needsBottomPadding ? insets.bottom : 0) + + (needsExtraMinHeight ? 20 : 0), maxHeight: tabBarHeight + (needsBottomPadding ? insets.bottom : 0), paddingBottom: needsBottomPadding ? insets.bottom : 0, + justifyContent: activeSessionType === "terminal" ? "center" : undefined, }} focusable={false} > diff --git a/app/Tabs/Sessions/Sessions.tsx b/app/Tabs/Sessions/Sessions.tsx index 9582aef..9ec7bd9 100644 --- a/app/Tabs/Sessions/Sessions.tsx +++ b/app/Tabs/Sessions/Sessions.tsx @@ -72,6 +72,7 @@ export default function Sessions() { Dimensions.get("window"), ); const [keyboardType, setKeyboardType] = useState("default"); + const lastBlurTimeRef = useRef(0); // Calculate responsive keyboard heights and margins const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); @@ -92,7 +93,7 @@ export default function Sessions() { const getTabBarBottomPosition = () => { const position = (() => { if (activeSession?.type !== "terminal") { - return 0; // Non-terminal sessions: sits at bottom + return insets.bottom; // Non-terminal sessions: sits at bottom with safe area padding } // Terminal session positioning - TabBar sits above KeyboardBar and any keyboards @@ -133,7 +134,7 @@ export default function Sessions() { const getBottomMargin = ( sessionType: "terminal" | "stats" | "filemanager" = "terminal", ) => { - // For non-terminal sessions, just the session tab bar + // For non-terminal sessions, just the session tab bar + bottom safe area if (sessionType !== "terminal") { return SESSION_TAB_BAR_HEIGHT + insets.bottom; } @@ -761,22 +762,17 @@ export default function Sessions() { setKeyboardIntentionallyHidden(false); }} onBlur={() => { - // Use a longer delay to avoid flicker from accidental touches - // but still maintain focus for typing + // Immediately refocus to prevent keyboard from closing when touching terminal + // This prevents the flicker/dismiss that happens when touching the WebView if ( !keyboardIntentionallyHiddenRef.current && !isCustomKeyboardVisible && activeSession?.type === "terminal" ) { - setTimeout(() => { - if ( - !keyboardIntentionallyHiddenRef.current && - !isCustomKeyboardVisible && - activeSession?.type === "terminal" - ) { - hiddenInputRef.current?.focus(); - } - }, 200); // 200ms delay to allow intentional taps to complete + // Use requestAnimationFrame for immediate refocus + requestAnimationFrame(() => { + hiddenInputRef.current?.focus(); + }); } }} /> From af9fd5774eeb87194e3f0ca05f803df9339b9e64 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 7 Dec 2025 17:46:10 -0600 Subject: [PATCH 07/27] fix: Fix the snippets bar visibility and general issues with landscape styling --- app/Tabs/Sessions/BottomToolbar.tsx | 5 +- app/Tabs/Sessions/CustomKeyboard.tsx | 3 +- app/Tabs/Sessions/FileManager.tsx | 3 +- .../FileManager/FileManagerToolbar.tsx | 2 +- app/Tabs/Sessions/Navigation/TabBar.tsx | 9 +- app/Tabs/Sessions/Sessions.tsx | 130 +++++----- app/Tabs/Sessions/SnippetsBar.tsx | 229 ++++++++++-------- app/Tabs/Sessions/Terminal.tsx | 5 +- 8 files changed, 201 insertions(+), 185 deletions(-) diff --git a/app/Tabs/Sessions/BottomToolbar.tsx b/app/Tabs/Sessions/BottomToolbar.tsx index 49feb58..6b60234 100644 --- a/app/Tabs/Sessions/BottomToolbar.tsx +++ b/app/Tabs/Sessions/BottomToolbar.tsx @@ -44,10 +44,7 @@ export default function BottomToolbar({ return ( {/* Tab Bar */} + {visibleRows.map((row, rowIndex) => ( diff --git a/app/Tabs/Sessions/FileManager.tsx b/app/Tabs/Sessions/FileManager.tsx index 89054e2..55e008a 100644 --- a/app/Tabs/Sessions/FileManager.tsx +++ b/app/Tabs/Sessions/FileManager.tsx @@ -527,10 +527,11 @@ export const FileManager = forwardRef( return ( { if (keyboardIntentionallyHiddenRef.current) { @@ -94,13 +93,9 @@ export default function TabBar({ borderBottomWidth: activeSessionType === "terminal" ? BORDERS.STANDARD : 0, borderBottomColor: BORDER_COLORS.PRIMARY, - minHeight: - tabBarHeight + - (needsBottomPadding ? insets.bottom : 0) + - (needsExtraMinHeight ? 20 : 0), - maxHeight: tabBarHeight + (needsBottomPadding ? insets.bottom : 0), + height: tabBarHeight + (needsBottomPadding ? insets.bottom : 0), paddingBottom: needsBottomPadding ? insets.bottom : 0, - justifyContent: activeSessionType === "terminal" ? "center" : undefined, + justifyContent: activeSessionType === "terminal" ? "center" : "flex-start", }} focusable={false} > diff --git a/app/Tabs/Sessions/Sessions.tsx b/app/Tabs/Sessions/Sessions.tsx index 9ec7bd9..1157f5f 100644 --- a/app/Tabs/Sessions/Sessions.tsx +++ b/app/Tabs/Sessions/Sessions.tsx @@ -35,7 +35,7 @@ import KeyboardBar from "@/app/Tabs/Sessions/KeyboardBar"; import { ArrowLeft } from "lucide-react-native"; import { useOrientation } from "@/app/utils/orientation"; import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; -import { BACKGROUNDS, BORDER_COLORS } from "@/app/constants/designTokens"; +import { BACKGROUNDS, BORDER_COLORS, BORDERS } from "@/app/constants/designTokens"; export default function Sessions() { const insets = useSafeAreaInsets(); @@ -83,51 +83,42 @@ export default function Sessions() { ? Math.min(keyboardHeight, maxKeyboardHeight) : keyboardHeight; - // Component height constants - const SESSION_TAB_BAR_HEIGHT = getTabBarHeight(isLandscape); // 50-60px + // Custom keyboard height MUST match BottomToolbar.tsx calculation (line 34) + const customKeyboardHeight = Math.max(200, Math.min(effectiveKeyboardHeight, 500)); + + // Component height constants (responsive to landscape) + const SESSION_TAB_BAR_HEIGHT = getTabBarHeight(isLandscape) + 2; // Content height + 2px top border (BORDERS.MAJOR) const CUSTOM_KEYBOARD_TAB_HEIGHT = 36; - const KEYBOARD_BAR_HEIGHT = 52; // Normal keyboard bar height - const KEYBOARD_BAR_HEIGHT_EXTENDED = 66; // When keyboard intentionally hidden + // KeyboardBar heights: paddingVertical (6px landscape, 8px portrait) + 36px key + paddingBottom when hidden + const KEYBOARD_BAR_HEIGHT = isLandscape ? 48 : 52; // 6+36+6=48 landscape, 8+36+8=52 portrait + const KEYBOARD_BAR_HEIGHT_EXTENDED = isLandscape ? 64 : 68; // +16px extra paddingBottom when hidden // Helper function to calculate TabBar bottom position const getTabBarBottomPosition = () => { - const position = (() => { - if (activeSession?.type !== "terminal") { - return insets.bottom; // Non-terminal sessions: sits at bottom with safe area padding - } - - // Terminal session positioning - TabBar sits above KeyboardBar and any keyboards - if (keyboardIntentionallyHiddenRef.current) { - return KEYBOARD_BAR_HEIGHT_EXTENDED; // Above extended keyboard bar - } - - if (isCustomKeyboardVisible) { - // Above: KeyboardBar + BottomToolbar (which includes tabs + content) - // BottomToolbar height = CUSTOM_KEYBOARD_TAB_HEIGHT + effectiveKeyboardHeight - return KEYBOARD_BAR_HEIGHT + CUSTOM_KEYBOARD_TAB_HEIGHT + effectiveKeyboardHeight; - } + // Non-terminal sessions: sits at bottom with safe area padding + if (activeSession?.type !== "terminal") { + return insets.bottom; + } - if (isKeyboardVisible && currentKeyboardHeight > 0) { - // Above: KeyboardBar + system keyboard - return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; - } + // Terminal session positioning - TabBar sits above KeyboardBar and any keyboards + // PRIORITY 1: Custom keyboard (check FIRST to avoid race condition) + if (isCustomKeyboardVisible) { + // Above BottomToolbar only (KeyboardBar hidden when custom keyboard visible) + return CUSTOM_KEYBOARD_TAB_HEIGHT + customKeyboardHeight; + } - return KEYBOARD_BAR_HEIGHT; // Just above keyboard bar (no keyboard showing) - })(); + // PRIORITY 2: Keyboard intentionally hidden (chevron down) + if (keyboardIntentionallyHiddenRef.current) { + return KEYBOARD_BAR_HEIGHT_EXTENDED; + } - console.log('[TabBar Position]', { - activeSessionType: activeSession?.type, - isCustomKeyboardVisible, - keyboardIntentionallyHidden: keyboardIntentionallyHiddenRef.current, - isKeyboardVisible, - currentKeyboardHeight, - KEYBOARD_BAR_HEIGHT, - CUSTOM_KEYBOARD_TAB_HEIGHT, - effectiveKeyboardHeight, - calculatedPosition: position, - }); + // PRIORITY 3: System keyboard visible + if (isKeyboardVisible && currentKeyboardHeight > 0) { + return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; + } - return position; + // DEFAULT: Just above keyboard bar (no keyboard showing) + return KEYBOARD_BAR_HEIGHT; }; // Calculate bottom margins for content (terminal content area) @@ -139,28 +130,24 @@ export default function Sessions() { return SESSION_TAB_BAR_HEIGHT + insets.bottom; } - // Terminal sessions need to account for: SessionTabBar + KeyboardBar + (optional keyboard) - let margin = SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT; + // PRIORITY 1: Custom keyboard (check FIRST to avoid race condition) + if (isCustomKeyboardVisible) { + // BottomToolbar replaces KeyboardBar entirely - calculate from scratch + return SESSION_TAB_BAR_HEIGHT + CUSTOM_KEYBOARD_TAB_HEIGHT + customKeyboardHeight; + } + // PRIORITY 2: Keyboard intentionally hidden (chevron down) if (keyboardIntentionallyHiddenRef.current) { - // No keyboard, but extended keyboard bar return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT_EXTENDED; } - if (isCustomKeyboardVisible) { - // Custom keyboard showing: add tab bar + keyboard content - margin += CUSTOM_KEYBOARD_TAB_HEIGHT + effectiveKeyboardHeight; - return margin; - } - + // PRIORITY 3: System keyboard visible if (isKeyboardVisible && currentKeyboardHeight > 0) { - // System keyboard showing - margin += currentKeyboardHeight; - return margin; + return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; } - // No keyboard showing, just bars - return margin; + // DEFAULT: Just KeyboardBar visible (no keyboard showing) + return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT; }; useEffect(() => { @@ -383,17 +370,18 @@ export default function Sessions() { if (isCustomKeyboardVisible) { // Closing custom keyboard - reopen system keyboard toggleCustomKeyboard(); + setKeyboardIntentionallyHidden(false); setTimeout(() => { - setKeyboardIntentionallyHidden(false); hiddenInputRef.current?.focus(); }, 150); } else { - // Opening custom keyboard - close system keyboard - setKeyboardIntentionallyHidden(true); - Keyboard.dismiss(); - setTimeout(() => { - toggleCustomKeyboard(); - }, 100); + // Opening custom keyboard - toggle state first, then blur to prevent auto-refocus + toggleCustomKeyboard(); // Update state immediately + setKeyboardIntentionallyHidden(false); + // Blur on next frame after state updates to prevent onBlur from refocusing + requestAnimationFrame(() => { + hiddenInputRef.current?.blur(); + }); } }; @@ -410,9 +398,15 @@ export default function Sessions() { return ( )} - {sessions.length > 0 && activeSession?.type === "terminal" && ( + {sessions.length > 0 && activeSession?.type === "terminal" && !isCustomKeyboardVisible && ( 0 - ? currentKeyboardHeight - : 0, + : isKeyboardVisible && currentKeyboardHeight > 0 + ? currentKeyboardHeight + (isLandscape ? 4 : 0) + : 0, left: 0, right: 0, height: keyboardIntentionallyHiddenRef.current ? KEYBOARD_BAR_HEIGHT_EXTENDED : KEYBOARD_BAR_HEIGHT, zIndex: 1003, overflow: "visible", + justifyContent: "center", }} > @@ -643,6 +636,7 @@ export default function Sessions() { isCustomKeyboardVisible && activeSession?.type === "terminal" && ( () } isVisible={isCustomKeyboardVisible} - keyboardHeight={effectiveKeyboardHeight} + keyboardHeight={customKeyboardHeight} isKeyboardIntentionallyHidden={ keyboardIntentionallyHiddenRef.current } diff --git a/app/Tabs/Sessions/SnippetsBar.tsx b/app/Tabs/Sessions/SnippetsBar.tsx index 03a305e..a56e6bb 100644 --- a/app/Tabs/Sessions/SnippetsBar.tsx +++ b/app/Tabs/Sessions/SnippetsBar.tsx @@ -15,8 +15,12 @@ interface Snippet { id: number; name: string; content: string; - folderId: number | null; - sortOrder: number; + description?: string | null; + folder: string | null; + order: number; + userId: string; + createdAt: string; + updatedAt: string; } interface SnippetFolder { @@ -24,7 +28,9 @@ interface SnippetFolder { name: string; color: string | null; icon: string | null; - sortOrder: number; + userId: string; + createdAt: string; + updatedAt: string; } interface SnippetsBarProps { @@ -55,22 +61,27 @@ export default function SnippetsBar({ try { setLoading(true); const [snippetsData, foldersData] = await Promise.all([ - getSnippets().catch(() => []), - getSnippetFolders().catch(() => []), + getSnippets().catch((err) => { + console.error("Failed to fetch snippets:", err); + return []; + }), + getSnippetFolders().catch((err) => { + console.error("Failed to fetch snippet folders:", err); + return []; + }), ]); + // Ensure we have arrays and proper data structure + const snippetsArray = Array.isArray(snippetsData) ? snippetsData : []; + const foldersArray = Array.isArray(foldersData) ? foldersData : []; + setSnippets( - (snippetsData || []).sort( - (a: Snippet, b: Snippet) => a.sortOrder - b.sortOrder, - ), - ); - setFolders( - (foldersData || []).sort( - (a: SnippetFolder, b: SnippetFolder) => a.sortOrder - b.sortOrder, - ), + snippetsArray.sort((a: Snippet, b: Snippet) => a.order - b.order), ); + setFolders(foldersArray); } catch (error) { console.error("Failed to load snippets:", error); + showToast.error("Failed to load snippets"); setSnippets([]); setFolders([]); } finally { @@ -97,107 +108,66 @@ export default function SnippetsBar({ }); }; - const getSnippetsInFolder = (folderId: number | null) => { - return snippets.filter((s) => s.folderId === folderId); + const getSnippetsInFolder = (folderName: string | null) => { + return snippets.filter((s) => s.folder === folderName); }; if (!isVisible) return null; - if (loading) { - return ( - - - - ); - } - const unfolderedSnippets = getSnippetsInFolder(null); + return ( - - Snippets - - - - - - - {unfolderedSnippets.map((snippet) => ( - executeSnippet(snippet)} - > - - {snippet.name} - - - ))} - - {folders.map((folder) => { - const folderSnippets = getSnippetsInFolder(folder.id); - const isCollapsed = collapsedFolders.has(folder.id); - - return ( - + {loading ? ( + + + Loading snippets... + + ) : ( + + {unfolderedSnippets.length > 0 && ( + toggleFolder(folder.id)} + onPress={() => toggleFolder(0)} > - {folder.icon && ( - {folder.icon} - )} - {folder.name} + Uncategorized - - ({folderSnippets.length}) + + ({unfolderedSnippets.length}) - - {isCollapsed ? "▶" : "▼"} + + {collapsedFolders.has(0) ? "▶" : "▼"} - {!isCollapsed && - folderSnippets.map((snippet) => ( + {!collapsedFolders.has(0) && + unfolderedSnippets.map((snippet) => ( executeSnippet(snippet)} > {snippet.name} @@ -214,20 +184,77 @@ export default function SnippetsBar({ ))} - ); - })} - - {snippets.length === 0 && ( - - - No snippets yet - - - Create snippets in Settings - - - )} - + )} + + {folders.map((folder) => { + const folderSnippets = getSnippetsInFolder(folder.name); + const isCollapsed = collapsedFolders.has(folder.id); + + return ( + + toggleFolder(folder.id)} + > + + + {folder.name} + + + ({folderSnippets.length}) + + + + {isCollapsed ? "▶" : "▼"} + + + + {!isCollapsed && + folderSnippets.map((snippet) => ( + executeSnippet(snippet)} + > + + {snippet.name} + + + ))} + + ); + })} + + {snippets.length === 0 && ( + + + No snippets yet + + + Create snippets in the Termix web/desktop version + + + )} + + )} ); } diff --git a/app/Tabs/Sessions/Terminal.tsx b/app/Tabs/Sessions/Terminal.tsx index b98b598..2a72f10 100644 --- a/app/Tabs/Sessions/Terminal.tsx +++ b/app/Tabs/Sessions/Terminal.tsx @@ -567,7 +567,8 @@ const TerminalComponent = forwardRef( setHtmlContent(html); }; updateHtml(); - }, [generateHTML]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only generate HTML on mount, not on dimension changes const handleWebViewMessage = useCallback( (event: any) => { @@ -594,7 +595,7 @@ const TerminalComponent = forwardRef( setRetryCount(0); // Log terminal activity - logActivity("terminal", hostConfig.id).catch(() => {}); + logActivity("terminal", hostConfig.id, hostConfig.name).catch(() => {}); break; case "dataReceived": From acddd261783d97f986060c83af07d1cb37ca8a88 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 7 Dec 2025 17:58:32 -0600 Subject: [PATCH 08/27] chore: Format and clean up files --- app/Tabs/Sessions/BottomToolbar.tsx | 9 +- app/Tabs/Sessions/CommandHistoryBar.tsx | 272 ------------------ app/Tabs/Sessions/FileManager.tsx | 25 +- app/Tabs/Sessions/FileManager/ContextMenu.tsx | 2 - app/Tabs/Sessions/FileManager/FileItem.tsx | 4 - .../FileManager/FileManagerHeader.tsx | 2 - .../FileManager/FileManagerToolbar.tsx | 9 - app/Tabs/Sessions/FileManager/FileViewer.tsx | 2 - .../Sessions/FileManager/utils/fileUtils.ts | 56 ++-- app/Tabs/Sessions/Navigation/TabBar.tsx | 4 +- app/Tabs/Sessions/ServerStats.tsx | 7 - app/Tabs/Sessions/Sessions.tsx | 153 ++++------ app/Tabs/Sessions/SnippetsBar.tsx | 6 +- app/Tabs/Sessions/Terminal.tsx | 37 +-- app/constants/designTokens.ts | 60 ++-- app/contexts/TerminalSessionsContext.tsx | 30 +- app/main-axios.ts | 4 - app/utils/orientation.ts | 24 -- app/utils/responsive.ts | 46 +-- 19 files changed, 146 insertions(+), 606 deletions(-) delete mode 100644 app/Tabs/Sessions/CommandHistoryBar.tsx diff --git a/app/Tabs/Sessions/BottomToolbar.tsx b/app/Tabs/Sessions/BottomToolbar.tsx index 6b60234..d58224a 100644 --- a/app/Tabs/Sessions/BottomToolbar.tsx +++ b/app/Tabs/Sessions/BottomToolbar.tsx @@ -30,7 +30,6 @@ export default function BottomToolbar({ if (!isVisible) return null; - // Constrain keyboard height to safe values const safeKeyboardHeight = Math.max(200, Math.min(keyboardHeight, 500)); const tabs: { id: ToolbarMode; label: string }[] = [ @@ -38,15 +37,10 @@ export default function BottomToolbar({ { id: "snippets", label: "SNIPPETS" }, ]; - // Total height includes tab bar + content area (padding handled separately) const TAB_BAR_HEIGHT = 36; return ( - - {/* Tab Bar */} + - {/* Content Area */} ; - isVisible: boolean; - height: number; - currentHostId?: number; -} - -export default function CommandHistoryBar({ - terminalRef, - isVisible, - height, - currentHostId, -}: CommandHistoryBarProps) { - const [history, setHistory] = useState([]); - const [filteredHistory, setFilteredHistory] = useState( - [], - ); - const [searchQuery, setSearchQuery] = useState(""); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (isVisible) { - loadHistory(); - } - }, [isVisible]); - - useEffect(() => { - filterHistory(); - }, [searchQuery, history]); - - const loadHistory = async () => { - try { - setLoading(true); - // Don't load if no currentHostId - if (!currentHostId) { - setHistory([]); - setLoading(false); - return; - } - const historyData = await getCommandHistory(); - - // Sort by timestamp descending (most recent first) - const sortedHistory = historyData.sort( - (a: CommandHistoryItem, b: CommandHistoryItem) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), - ); - - setHistory(sortedHistory); - } catch (error) { - showToast.error("Failed to load command history"); - } finally { - setLoading(false); - } - }; - - const filterHistory = () => { - let filtered = history; - - // Filter by current host if specified - if (currentHostId) { - filtered = filtered.filter((item) => item.hostId === currentHostId); - } - - // Filter by search query - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter((item) => - item.command.toLowerCase().includes(query), - ); - } - - setFilteredHistory(filtered); - }; - - const executeCommand = (command: string) => { - if (terminalRef.current) { - terminalRef.current.sendInput(command + "\n"); - showToast.success("Command executed"); - } - }; - - const deleteCommand = async (commandId: number) => { - try { - await deleteCommandFromHistory(commandId); - setHistory((prev) => prev.filter((item) => item.id !== commandId)); - showToast.success("Command deleted"); - } catch (error) { - showToast.error("Failed to delete command"); - } - }; - - const clearAll = async () => { - try { - await clearCommandHistory(); - setHistory([]); - showToast.success("History cleared"); - } catch (error) { - showToast.error("Failed to clear history"); - } - }; - - const formatTimestamp = (timestamp: string) => { - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - - return date.toLocaleDateString(); - }; - - if (!isVisible) return null; - - if (loading) { - return ( - - - - ); - } - - return ( - - - - Command History - - - - - - {history.length > 0 && ( - - 🗑 - - )} - - - - - - - - - {filteredHistory.map((item) => ( - - executeCommand(item.command)} - > - - {item.command} - - - - {item.hostName} - - - {formatTimestamp(item.timestamp)} - - - - deleteCommand(item.id)} - > - × - - - ))} - - {filteredHistory.length === 0 && !searchQuery && ( - - - No command history yet - - - Commands you run will appear here - - - )} - - {filteredHistory.length === 0 && searchQuery && ( - - - No matching commands - - - Try a different search term - - - )} - - - ); -} diff --git a/app/Tabs/Sessions/FileManager.tsx b/app/Tabs/Sessions/FileManager.tsx index 55e008a..d75ace5 100644 --- a/app/Tabs/Sessions/FileManager.tsx +++ b/app/Tabs/Sessions/FileManager.tsx @@ -83,7 +83,6 @@ export const FileManager = forwardRef( const [isConnected, setIsConnected] = useState(false); const [sshSessionId, setSshSessionId] = useState(null); - // Selection and clipboard const [selectionMode, setSelectionMode] = useState(false); const [selectedFiles, setSelectedFiles] = useState([]); const [clipboard, setClipboard] = useState<{ @@ -91,7 +90,6 @@ export const FileManager = forwardRef( operation: "copy" | "cut" | null; }>({ files: [], operation: null }); - // Dialogs const [contextMenu, setContextMenu] = useState<{ visible: boolean; file: FileItem | null; @@ -114,10 +112,8 @@ export const FileManager = forwardRef( content: string; }>({ visible: false, file: null, content: "" }); - // Keepalive const keepaliveInterval = useRef(null); - // Connect to SSH const connectToSSH = useCallback(async () => { try { setIsLoading(true); @@ -142,12 +138,10 @@ export const FileManager = forwardRef( setSshSessionId(sessionId); setIsConnected(true); - // Start keepalive keepaliveInterval.current = setInterval(() => { keepSSHAlive(sessionId).catch(() => {}); }, 30000); - // Load initial directory await loadDirectory(host.defaultPath || "/"); } catch (error: any) { showToast.error(error.message || "Failed to connect to SSH"); @@ -164,12 +158,10 @@ export const FileManager = forwardRef( setSshSessionId(sessionId); setIsConnected(true); - // Start keepalive keepaliveInterval.current = setInterval(() => { keepSSHAlive(sessionId).catch(() => {}); }, 30000); - // Load initial directory await loadDirectory(host.defaultPath || "/"); } catch (error: any) { showToast.error(error.message || "Invalid TOTP code"); @@ -194,19 +186,15 @@ export const FileManager = forwardRef( [sessionId], ); - // File operations const handleFilePress = async (file: FileItem) => { - // Handle symlinks by resolving target first if (file.type === "link") { try { setIsLoading(true); const symlinkInfo = await identifySSHSymlink(sessionId!, file.path); if (symlinkInfo.type === "directory") { - // Navigate to target directory await loadDirectory(symlinkInfo.target); } else if (isTextFile(symlinkInfo.target)) { - // View target file const targetFile: FileItem = { name: file.name, path: symlinkInfo.target, @@ -224,7 +212,6 @@ export const FileManager = forwardRef( return; } - // Handle regular files and directories if (file.type === "directory") { loadDirectory(file.path); } else { @@ -412,7 +399,6 @@ export const FileManager = forwardRef( setSelectedFiles([]); }; - // Initialize useEffect(() => { connectToSSH(); @@ -423,7 +409,6 @@ export const FileManager = forwardRef( }; }, [connectToSSH]); - // Expose disconnect method to parent useImperativeHandle(ref, () => ({ handleDisconnect: () => { if (keepaliveInterval.current) { @@ -439,7 +424,6 @@ export const FileManager = forwardRef( Connecting to {host.name}... - {/* TOTP Dialog */} ( const padding = getResponsivePadding(isLandscape); const tabBarHeight = getTabBarHeight(isLandscape); - // Calculate toolbar height (only visible when in selection mode or clipboard has items) const toolbarPaddingVertical = isLandscape ? 8 : 12; - const toolbarContentHeight = isLandscape ? 34 : 44; // Approximate content height + const toolbarContentHeight = isLandscape ? 34 : 44; const toolbarBorderHeight = 2; const effectiveToolbarHeight = selectionMode || clipboard.files.length > 0 @@ -531,7 +514,7 @@ export const FileManager = forwardRef( style={{ opacity: isVisible ? 1 : 0, display: isVisible ? "flex" : "none", - backgroundColor: BACKGROUNDS.HEADER, // Match FileManagerHeader background (#131316) + backgroundColor: BACKGROUNDS.HEADER, }} > ( tabBarHeight={tabBarHeight} /> - {/* Context Menu */} {contextMenu.file && ( ( /> )} - {/* Create Dialog */} ( - {/* Rename Dialog */} ( - {/* File Viewer */} {fileViewer.file && ( {}}> - {/* Header */} - {/* Actions */} {onView && fileType === "file" && ( - {/* Selection Checkbox (visible in selection mode) */} {selectionMode && ( )} - {/* File Icon */} - {/* File Info */} {name} @@ -104,7 +101,6 @@ export function FileItem({ - {/* Link indicator */} {type === "link" && !selectionMode && ( diff --git a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx index bddd60e..daf8ae3 100644 --- a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx +++ b/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx @@ -60,7 +60,6 @@ export function FileManagerHeader({ paddingHorizontal: Math.max(insets.left, insets.right, padding), }} > - {/* Path breadcrumbs */} - {/* Action buttons */} {selectionMode ? ( - {/* Selection count */} - {/* Copy */} - {/* Cut */} - {/* Delete */} - {/* Cancel */} ) : ( - {/* Clipboard info */} - {/* Paste */} - {/* Cancel */} {onCancelClipboard && ( - {/* Header */} - {/* Code Editor */} { - // Always put directories first if (a.type === "directory" && b.type !== "directory") return -1; if (a.type !== "directory" && b.type === "directory") return 1; @@ -166,59 +163,48 @@ export function sortFiles( } export function getFileIconColor(filename: string, type: string): string { - if (type === "directory") return "#3B82F6"; // blue - if (type === "link") return "#8B5CF6"; // purple + if (type === "directory") return "#3B82F6"; + if (type === "link") return "#8B5CF6"; const ext = getFileExtension(filename); - // Code files - if (["js", "jsx", "ts", "tsx"].includes(ext)) return "#F59E0B"; // amber - if (["py"].includes(ext)) return "#3B82F6"; // blue - if (["java", "class"].includes(ext)) return "#EF4444"; // red - if (["c", "cpp", "h", "hpp"].includes(ext)) return "#06B6D4"; // cyan - if (["go"].includes(ext)) return "#06B6D4"; // cyan - if (["rs"].includes(ext)) return "#F97316"; // orange + if (["js", "jsx", "ts", "tsx"].includes(ext)) return "#F59E0B"; + if (["py"].includes(ext)) return "#3B82F6"; + if (["java", "class"].includes(ext)) return "#EF4444"; + if (["c", "cpp", "h", "hpp"].includes(ext)) return "#06B6D4"; + if (["go"].includes(ext)) return "#06B6D4"; + if (["rs"].includes(ext)) return "#F97316"; - // Web files - if (["html", "htm"].includes(ext)) return "#F97316"; // orange - if (["css", "scss", "sass", "less"].includes(ext)) return "#3B82F6"; // blue - if (["json", "xml"].includes(ext)) return "#F59E0B"; // amber + if (["html", "htm"].includes(ext)) return "#F97316"; + if (["css", "scss", "sass", "less"].includes(ext)) return "#3B82F6"; + if (["json", "xml"].includes(ext)) return "#F59E0B"; - // Config files if (["yml", "yaml", "toml", "ini", "conf", "cfg"].includes(ext)) - return "#8B5CF6"; // purple - if (["env", "gitignore", "dockerignore"].includes(ext)) return "#6B7280"; // gray + return "#8B5CF6"; + if (["env", "gitignore", "dockerignore"].includes(ext)) return "#6B7280"; - // Documents - if (["md", "txt"].includes(ext)) return "#10B981"; // green - if (["pdf"].includes(ext)) return "#EF4444"; // red - if (["doc", "docx"].includes(ext)) return "#3B82F6"; // blue + if (["md", "txt"].includes(ext)) return "#10B981"; + if (["pdf"].includes(ext)) return "#EF4444"; + if (["doc", "docx"].includes(ext)) return "#3B82F6"; - // Archives - if (isArchiveFile(filename)) return "#8B5CF6"; // purple + if (isArchiveFile(filename)) return "#8B5CF6"; - // Images - if (isImageFile(filename)) return "#EC4899"; // pink + if (isImageFile(filename)) return "#EC4899"; - // Videos - if (isVideoFile(filename)) return "#F59E0B"; // amber + if (isVideoFile(filename)) return "#F59E0B"; - // Shell scripts - if (["sh", "bash", "zsh", "fish"].includes(ext)) return "#10B981"; // green + if (["sh", "bash", "zsh", "fish"].includes(ext)) return "#10B981"; - // Default - return "#9CA3AF"; // gray-400 + return "#9CA3AF"; } export function breadcrumbsFromPath(path: string): string[] { if (!path || path === "/") return ["/"]; - // Split and filter empty strings to handle paths properly const parts = path.split("/").filter((p) => p.trim() !== ""); const breadcrumbs: string[] = ["/"]; - // Build cumulative paths without double slashes parts.forEach((part, index) => { const cumulativeParts = parts.slice(0, index + 1); const breadcrumbPath = "/" + cumulativeParts.join("/"); diff --git a/app/Tabs/Sessions/Navigation/TabBar.tsx b/app/Tabs/Sessions/Navigation/TabBar.tsx index caed606..f2ae8a4 100644 --- a/app/Tabs/Sessions/Navigation/TabBar.tsx +++ b/app/Tabs/Sessions/Navigation/TabBar.tsx @@ -65,7 +65,6 @@ export default function TabBar({ const tabBarHeight = getTabBarHeight(isLandscape); const buttonSize = getButtonSize(isLandscape); - // Add bottom padding for non-terminal sessions (when tab bar is at the bottom) const needsBottomPadding = activeSessionType !== "terminal"; const handleToggleSystemKeyboard = () => { @@ -95,7 +94,8 @@ export default function TabBar({ borderBottomColor: BORDER_COLORS.PRIMARY, height: tabBarHeight + (needsBottomPadding ? insets.bottom : 0), paddingBottom: needsBottomPadding ? insets.bottom : 0, - justifyContent: activeSessionType === "terminal" ? "center" : "flex-start", + justifyContent: + activeSessionType === "terminal" ? "center" : "flex-start", }} focusable={false} > diff --git a/app/Tabs/Sessions/ServerStats.tsx b/app/Tabs/Sessions/ServerStats.tsx index b4f2257..de7baff 100644 --- a/app/Tabs/Sessions/ServerStats.tsx +++ b/app/Tabs/Sessions/ServerStats.tsx @@ -108,7 +108,6 @@ export const ServerStats = forwardRef( if (isVisible) { fetchMetrics(); - // Auto-refresh every 5 seconds refreshIntervalRef.current = setInterval(() => { fetchMetrics(false); }, 5000); @@ -296,7 +295,6 @@ export const ServerStats = forwardRef( /> } > - {/* Header */} ( - {/* Grid Container */} ( gap: 12, }} > - {/* CPU Metrics */} {renderMetricCard( , "CPU Usage", @@ -330,7 +326,6 @@ export const ServerStats = forwardRef( "#60A5FA", )} - {/* Load Average */} {metrics?.cpu?.load && ( ( )} - {/* Memory Metrics */} {renderMetricCard( , "Memory Usage", @@ -433,7 +427,6 @@ export const ServerStats = forwardRef( "#34D399", )} - {/* Disk Metrics */} {renderMetricCard( , "Disk Usage", diff --git a/app/Tabs/Sessions/Sessions.tsx b/app/Tabs/Sessions/Sessions.tsx index 1157f5f..3f30bfc 100644 --- a/app/Tabs/Sessions/Sessions.tsx +++ b/app/Tabs/Sessions/Sessions.tsx @@ -35,7 +35,11 @@ import KeyboardBar from "@/app/Tabs/Sessions/KeyboardBar"; import { ArrowLeft } from "lucide-react-native"; import { useOrientation } from "@/app/utils/orientation"; import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; -import { BACKGROUNDS, BORDER_COLORS, BORDERS } from "@/app/constants/designTokens"; +import { + BACKGROUNDS, + BORDER_COLORS, + BORDERS, +} from "@/app/constants/designTokens"; export default function Sessions() { const insets = useSafeAreaInsets(); @@ -74,7 +78,6 @@ export default function Sessions() { const [keyboardType, setKeyboardType] = useState("default"); const lastBlurTimeRef = useRef(0); - // Calculate responsive keyboard heights and margins const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); const effectiveKeyboardHeight = isLandscape ? Math.min(lastKeyboardHeight, maxKeyboardHeight) @@ -83,70 +86,62 @@ export default function Sessions() { ? Math.min(keyboardHeight, maxKeyboardHeight) : keyboardHeight; - // Custom keyboard height MUST match BottomToolbar.tsx calculation (line 34) - const customKeyboardHeight = Math.max(200, Math.min(effectiveKeyboardHeight, 500)); + const customKeyboardHeight = Math.max( + 200, + Math.min(effectiveKeyboardHeight, 500), + ); - // Component height constants (responsive to landscape) - const SESSION_TAB_BAR_HEIGHT = getTabBarHeight(isLandscape) + 2; // Content height + 2px top border (BORDERS.MAJOR) + const SESSION_TAB_BAR_HEIGHT = getTabBarHeight(isLandscape) + 2; const CUSTOM_KEYBOARD_TAB_HEIGHT = 36; - // KeyboardBar heights: paddingVertical (6px landscape, 8px portrait) + 36px key + paddingBottom when hidden - const KEYBOARD_BAR_HEIGHT = isLandscape ? 48 : 52; // 6+36+6=48 landscape, 8+36+8=52 portrait - const KEYBOARD_BAR_HEIGHT_EXTENDED = isLandscape ? 64 : 68; // +16px extra paddingBottom when hidden - // Helper function to calculate TabBar bottom position + const KEYBOARD_BAR_HEIGHT = isLandscape ? 48 : 52; + const KEYBOARD_BAR_HEIGHT_EXTENDED = isLandscape ? 64 : 68; + const getTabBarBottomPosition = () => { - // Non-terminal sessions: sits at bottom with safe area padding if (activeSession?.type !== "terminal") { return insets.bottom; } - // Terminal session positioning - TabBar sits above KeyboardBar and any keyboards - // PRIORITY 1: Custom keyboard (check FIRST to avoid race condition) if (isCustomKeyboardVisible) { - // Above BottomToolbar only (KeyboardBar hidden when custom keyboard visible) return CUSTOM_KEYBOARD_TAB_HEIGHT + customKeyboardHeight; } - // PRIORITY 2: Keyboard intentionally hidden (chevron down) if (keyboardIntentionallyHiddenRef.current) { return KEYBOARD_BAR_HEIGHT_EXTENDED; } - // PRIORITY 3: System keyboard visible if (isKeyboardVisible && currentKeyboardHeight > 0) { return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; } - // DEFAULT: Just above keyboard bar (no keyboard showing) return KEYBOARD_BAR_HEIGHT; }; - // Calculate bottom margins for content (terminal content area) const getBottomMargin = ( sessionType: "terminal" | "stats" | "filemanager" = "terminal", ) => { - // For non-terminal sessions, just the session tab bar + bottom safe area if (sessionType !== "terminal") { return SESSION_TAB_BAR_HEIGHT + insets.bottom; } - // PRIORITY 1: Custom keyboard (check FIRST to avoid race condition) if (isCustomKeyboardVisible) { - // BottomToolbar replaces KeyboardBar entirely - calculate from scratch - return SESSION_TAB_BAR_HEIGHT + CUSTOM_KEYBOARD_TAB_HEIGHT + customKeyboardHeight; + return ( + SESSION_TAB_BAR_HEIGHT + + CUSTOM_KEYBOARD_TAB_HEIGHT + + customKeyboardHeight + ); } - // PRIORITY 2: Keyboard intentionally hidden (chevron down) if (keyboardIntentionallyHiddenRef.current) { return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT_EXTENDED; } - // PRIORITY 3: System keyboard visible if (isKeyboardVisible && currentKeyboardHeight > 0) { - return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; + return ( + SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT + currentKeyboardHeight + ); } - // DEFAULT: Just KeyboardBar visible (no keyboard showing) return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT; }; @@ -261,26 +256,6 @@ export default function Sessions() { } }, [sessions.length, isKeyboardVisible]); - // Remove the auto-focus after 3 seconds - it causes keyboard flickering - // useEffect(() => { - // if ( - // sessions.length > 0 && - // !isKeyboardVisible && - // !isCustomKeyboardVisible && - // !keyboardIntentionallyHiddenRef.current - // ) { - // const timeoutId = setTimeout(() => { - // hiddenInputRef.current?.focus(); - // }, 3000); - // return () => clearTimeout(timeoutId); - // } - // }, [ - // isKeyboardVisible, - // sessions.length, - // isCustomKeyboardVisible, - // keyboardIntentionallyHiddenRef, - // ]); - useEffect(() => { const subscription = Dimensions.addEventListener("change", ({ window }) => { setScreenDimensions(window); @@ -368,17 +343,14 @@ export default function Sessions() { const handleToggleKeyboard = () => { if (isCustomKeyboardVisible) { - // Closing custom keyboard - reopen system keyboard toggleCustomKeyboard(); setKeyboardIntentionallyHidden(false); setTimeout(() => { hiddenInputRef.current?.focus(); }, 150); } else { - // Opening custom keyboard - toggle state first, then blur to prevent auto-refocus - toggleCustomKeyboard(); // Update state immediately + toggleCustomKeyboard(); setKeyboardIntentionallyHidden(false); - // Blur on next frame after state updates to prevent onBlur from refocusing requestAnimationFrame(() => { hiddenInputRef.current?.blur(); }); @@ -403,10 +375,10 @@ export default function Sessions() { paddingTop: insets.top, backgroundColor: activeSession?.type === "terminal" - ? BACKGROUNDS.DARKEST // Terminal: #09090b + ? BACKGROUNDS.DARKEST : activeSession?.type === "filemanager" - ? BACKGROUNDS.HEADER // FileManager: #131316 - : "#18181b", // Stats: default app bg + ? BACKGROUNDS.HEADER + : "#18181b", }} > )} - {sessions.length > 0 && activeSession?.type === "terminal" && !isCustomKeyboardVisible && ( - 0 - ? currentKeyboardHeight + (isLandscape ? 4 : 0) - : 0, - left: 0, - right: 0, - height: keyboardIntentionallyHiddenRef.current ? KEYBOARD_BAR_HEIGHT_EXTENDED : KEYBOARD_BAR_HEIGHT, - zIndex: 1003, - overflow: "visible", - justifyContent: "center", - }} - > - () - } - isVisible={true} - onModifierChange={handleModifierChange} - isKeyboardIntentionallyHidden={ - keyboardIntentionallyHiddenRef.current - } - /> - - )} + {sessions.length > 0 && + activeSession?.type === "terminal" && + !isCustomKeyboardVisible && ( + 0 + ? currentKeyboardHeight + (isLandscape ? 4 : 0) + : 0, + left: 0, + right: 0, + height: keyboardIntentionallyHiddenRef.current + ? KEYBOARD_BAR_HEIGHT_EXTENDED + : KEYBOARD_BAR_HEIGHT, + zIndex: 1003, + overflow: "visible", + justifyContent: "center", + }} + > + () + } + isVisible={true} + onModifierChange={handleModifierChange} + isKeyboardIntentionallyHidden={ + keyboardIntentionallyHiddenRef.current + } + /> + + )} {sessions.length > 0 && (activeSession?.type === "stats" || @@ -691,9 +667,7 @@ export default function Sessions() { contextMenuHidden underlineColorAndroid="transparent" multiline - onChangeText={() => { - // Do nothing - we handle input in onKeyPress only - }} + onChangeText={() => {}} onKeyPress={({ nativeEvent }) => { const key = nativeEvent.key; const activeRef = activeSessionId @@ -704,7 +678,6 @@ export default function Sessions() { let finalKey = key; - // Handle modifiers if (activeModifiers.ctrl) { switch (key.toLowerCase()) { case "c": @@ -743,7 +716,6 @@ export default function Sessions() { finalKey = `\x1b${key}`; } - // Send the appropriate key if (key === "Enter") { activeRef.current.sendInput("\r"); } else if (key === "Backspace") { @@ -756,14 +728,11 @@ export default function Sessions() { setKeyboardIntentionallyHidden(false); }} onBlur={() => { - // Immediately refocus to prevent keyboard from closing when touching terminal - // This prevents the flicker/dismiss that happens when touching the WebView if ( !keyboardIntentionallyHiddenRef.current && !isCustomKeyboardVisible && activeSession?.type === "terminal" ) { - // Use requestAnimationFrame for immediate refocus requestAnimationFrame(() => { hiddenInputRef.current?.focus(); }); diff --git a/app/Tabs/Sessions/SnippetsBar.tsx b/app/Tabs/Sessions/SnippetsBar.tsx index a56e6bb..1da03cd 100644 --- a/app/Tabs/Sessions/SnippetsBar.tsx +++ b/app/Tabs/Sessions/SnippetsBar.tsx @@ -71,7 +71,6 @@ export default function SnippetsBar({ }), ]); - // Ensure we have arrays and proper data structure const snippetsArray = Array.isArray(snippetsData) ? snippetsData : []; const foldersArray = Array.isArray(foldersData) ? foldersData : []; @@ -116,13 +115,14 @@ export default function SnippetsBar({ const unfolderedSnippets = getSnippetsInFolder(null); - return ( {loading ? ( - Loading snippets... + + Loading snippets... + ) : ( ( } const baseFontSize = config.fontSize; - // Improved calculation based on font size - // Average monospace char width is roughly 0.6 * fontSize - // Line height is roughly 1.2 * fontSize const charWidth = baseFontSize * 0.6; const lineHeight = baseFontSize * 1.2; const terminalWidth = Math.floor(width / charWidth); @@ -372,7 +365,6 @@ const TerminalComponent = forwardRef( const terminalElement = document.getElementById('terminal'); - // Prevent focus on any textarea/input created by xterm document.addEventListener('focusin', function(e) { if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) { e.preventDefault(); @@ -435,10 +427,6 @@ const TerminalComponent = forwardRef( ws.send(JSON.stringify(connectMessage)); - // Disable terminal's built-in keyboard handling - // We use the hidden input element instead for better IME support - // terminal.onData is intentionally not used here - startPingInterval(); }; @@ -567,8 +555,8 @@ const TerminalComponent = forwardRef( setHtmlContent(html); }; updateHtml(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only generate HTML on mount, not on dimension changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleWebViewMessage = useCallback( (event: any) => { @@ -594,8 +582,9 @@ const TerminalComponent = forwardRef( setIsConnected(true); setRetryCount(0); - // Log terminal activity - logActivity("terminal", hostConfig.id, hostConfig.name).catch(() => {}); + logActivity("terminal", hostConfig.id, hostConfig.name).catch( + () => {}, + ); break; case "dataReceived": @@ -620,7 +609,6 @@ const TerminalComponent = forwardRef( `${message.data.hostName}: ${message.data.message}`, ); break; - } } catch (error) {} }, @@ -668,15 +656,6 @@ const TerminalComponent = forwardRef( } }, [hostConfig.id, currentHostId]); - // Focus handling removed - now managed by Sessions.tsx - // useEffect(() => { - // if (isVisible && isConnected && !showConnectingOverlay) { - // setTimeout(() => { - // focusTerminal(); - // }, 300); - // } - // }, [isVisible, isConnected, showConnectingOverlay, focusTerminal]); - useEffect(() => { return () => { if (connectionTimeoutRef.current) { @@ -685,9 +664,7 @@ const TerminalComponent = forwardRef( }; }, []); - const focusTerminal = useCallback(() => { - // Focus is now handled by Sessions.tsx - }, []); + const focusTerminal = useCallback(() => {}, []); return ( { - setSessions((prev) => { - const newSession = prev.find(s => s.id === sessionId); + const setActiveSession = useCallback( + (sessionId: string) => { + setSessions((prev) => { + const newSession = prev.find((s) => s.id === sessionId); - // Auto-close custom keyboard when switching to non-terminal sessions - if (newSession?.type !== 'terminal' && isCustomKeyboardVisible) { - setIsCustomKeyboardVisible(false); - } + if (newSession?.type !== "terminal" && isCustomKeyboardVisible) { + setIsCustomKeyboardVisible(false); + } - return prev.map((session) => ({ - ...session, - isActive: session.id === sessionId, - })); - }); - setActiveSessionId(sessionId); - }, [isCustomKeyboardVisible]); + return prev.map((session) => ({ + ...session, + isActive: session.id === sessionId, + })); + }); + setActiveSessionId(sessionId); + }, + [isCustomKeyboardVisible], + ); const navigateToSessions = useCallback( ( diff --git a/app/main-axios.ts b/app/main-axios.ts index cf2fd87..b18f83e 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -97,10 +97,6 @@ interface OIDCAuthorize { // UTILITY FUNCTIONS // ============================================================================ -export function isElectron(): boolean { - return false; -} - function getLoggerForService(serviceName: string) { if (serviceName.includes("SSH") || serviceName.includes("ssh")) { return sshLogger; diff --git a/app/utils/orientation.ts b/app/utils/orientation.ts index a728583..5370f5f 100644 --- a/app/utils/orientation.ts +++ b/app/utils/orientation.ts @@ -2,9 +2,6 @@ import { useWindowDimensions } from "react-native"; export type Orientation = "portrait" | "landscape"; -/** - * Hook to get current orientation and dimensions - */ export function useOrientation() { const { width, height } = useWindowDimensions(); const isLandscape = width > height; @@ -19,27 +16,6 @@ export function useOrientation() { }; } -/** - * Get responsive value based on orientation - */ -export function getResponsiveValue( - portraitValue: T, - landscapeValue: T, - isLandscape: boolean, -): T { - return isLandscape ? landscapeValue : portraitValue; -} - -/** - * Get percentage of dimension - */ -export function percentOf(dimension: number, percent: number): number { - return (dimension * percent) / 100; -} - -/** - * Clamp value between min and max - */ export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } diff --git a/app/utils/responsive.ts b/app/utils/responsive.ts index f82e004..8faa476 100644 --- a/app/utils/responsive.ts +++ b/app/utils/responsive.ts @@ -1,10 +1,3 @@ -/** - * Responsive utility functions for adaptive layouts - */ - -/** - * Get number of columns based on screen width and orientation - */ export function getColumnCount( width: number, isLandscape: boolean, @@ -13,65 +6,38 @@ export function getColumnCount( if (!isLandscape) return 1; const columns = Math.floor(width / itemMinWidth); - return Math.max(2, Math.min(columns, 3)); // Between 2-3 columns in landscape -} - -/** - * Calculate grid item width based on column count - */ -export function getGridItemWidth( - containerWidth: number, - columns: number, - gap: number = 16, -): number { - const totalGap = gap * (columns - 1); - return (containerWidth - totalGap) / columns; + return Math.max(2, Math.min(columns, 3)); } -/** - * Get responsive padding - */ export function getResponsivePadding( isLandscape: boolean, portraitPadding: number = 24, ): number { - return isLandscape ? portraitPadding * 0.67 : portraitPadding; // Reduce padding by 33% in landscape + return isLandscape ? portraitPadding * 0.67 : portraitPadding; } -/** - * Get responsive font size - */ export function getResponsiveFontSize( isLandscape: boolean, baseFontSize: number, ): number { - return isLandscape ? baseFontSize * 0.9 : baseFontSize; // Slightly smaller in landscape + return isLandscape ? baseFontSize * 0.9 : baseFontSize; } -/** - * Get max keyboard height for landscape mode - */ export function getMaxKeyboardHeight( screenHeight: number, isLandscape: boolean, ): number { - if (!isLandscape) return screenHeight; // No limit in portrait - return screenHeight * 0.4; // 40% max in landscape + if (!isLandscape) return screenHeight; + return screenHeight * 0.4; } -/** - * Get responsive tab bar height - */ export function getTabBarHeight(isLandscape: boolean): number { return isLandscape ? 50 : 60; } -/** - * Get responsive button size - */ export function getButtonSize( isLandscape: boolean, portraitSize: number = 44, ): number { - return isLandscape ? portraitSize * 0.82 : portraitSize; // ~36px in landscape vs 44px portrait + return isLandscape ? portraitSize * 0.82 : portraitSize; } From 553aa4b611c4facf56e76d4e80ef2fb070d64762 Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Sun, 7 Dec 2025 18:22:02 -0600 Subject: [PATCH 09/27] Update app.json --- app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.json b/app.json index 0fb3986..2f7afa1 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Termix", "slug": "termix", - "version": "1.1.0", + "version": "1.2.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "termix-mobile", From 5cf46f4e96379629135658203a733880a0e72ea0 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 8 Dec 2025 23:19:02 -0600 Subject: [PATCH 10/27] fix: Updated types --- app/Tabs/Hosts/Hosts.tsx | 3 +- app/Tabs/Sessions/Terminal.tsx | 2 +- app/main-axios.ts | 100 +--- types/index.ts | 878 +++++++++++++++++++++++---------- 4 files changed, 627 insertions(+), 356 deletions(-) diff --git a/app/Tabs/Hosts/Hosts.tsx b/app/Tabs/Hosts/Hosts.tsx index 0246738..6b68be1 100644 --- a/app/Tabs/Hosts/Hosts.tsx +++ b/app/Tabs/Hosts/Hosts.tsx @@ -18,9 +18,8 @@ import { getAllServerStatuses, initializeServerConfig, getCurrentServerUrl, - ServerStatus, } from "@/app/main-axios"; -import { SSHHost } from "@/types"; +import { SSHHost, ServerStatus } from "@/types"; import { useOrientation } from "@/app/utils/orientation"; import { getResponsivePadding, getColumnCount } from "@/app/utils/responsive"; diff --git a/app/Tabs/Sessions/Terminal.tsx b/app/Tabs/Sessions/Terminal.tsx index d502273..08888c2 100644 --- a/app/Tabs/Sessions/Terminal.tsx +++ b/app/Tabs/Sessions/Terminal.tsx @@ -28,7 +28,7 @@ interface TerminalProps { ip: string; port: number; username: string; - authType: "password" | "key" | "credential"; + authType: "password" | "key" | "credential" | "none"; password?: string; key?: string; keyPassword?: string; diff --git a/app/main-axios.ts b/app/main-axios.ts index b18f83e..40f9dde 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -10,6 +10,16 @@ import type { ApiResponse, FileManagerFile, FileManagerShortcut, + ServerStatus, + ServerMetrics, + AuthResponse, + UserInfo, + UserCount, + OIDCAuthorize, + FileManagerOperation, + ServerConfig, + UptimeInfo, + RecentActivityItem, } from "../types/index"; import { apiLogger, @@ -23,75 +33,11 @@ import { } from "../lib/frontend-logger"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import platform from "expo-modules-core/src/Platform"; +import { Platform } from "react-native"; -interface FileManagerOperation { - name: string; - path: string; - isSSH: boolean; - sshSessionId?: string; - hostId: number; -} - -export type ServerStatus = { - status: "online" | "offline"; - lastChecked: string; -}; - -interface CpuMetrics { - percent: number | null; - cores: number | null; - load: [number, number, number] | null; -} - -interface MemoryMetrics { - percent: number | null; - usedGiB: number | null; - totalGiB: number | null; -} - -interface DiskMetrics { - percent: number | null; - usedHuman: string | null; - totalHuman: string | null; -} +const platform = Platform; -export type ServerMetrics = { - cpu: CpuMetrics; - memory: MemoryMetrics; - disk: DiskMetrics; - lastChecked: string; -}; - -interface AuthResponse { - token: string; - success?: boolean; - is_admin?: boolean; - username?: string; - userId?: string; - is_oidc?: boolean; - totp_enabled?: boolean; - data_unlocked?: boolean; - requires_totp?: boolean; - temp_token?: string; -} - -interface UserInfo { - totp_enabled: boolean; - userId: string; - username: string; - is_admin: boolean; - is_oidc: boolean; - data_unlocked: boolean; -} - -interface UserCount { - count: number; -} - -interface OIDCAuthorize { - auth_url: string; -} +// All types are now imported from ../types/index.ts // ============================================================================ // UTILITY FUNCTIONS @@ -309,11 +255,6 @@ export function setAuthStateCallback( let configuredServerUrl: string | null = null; -export interface ServerConfig { - serverUrl: string; - lastUpdated: string; -} - export async function saveServerConfig(config: ServerConfig): Promise { try { await AsyncStorage.setItem("serverConfig", JSON.stringify(config)); @@ -3057,21 +2998,6 @@ export async function deleteSnippetFolder( // HOMEPAGE API // ============================================================================ -export interface UptimeInfo { - uptimeMs: number; - uptimeSeconds: number; - formatted: string; -} - -export interface RecentActivityItem { - id: number; - userId: string; - type: "terminal" | "file_manager"; - hostId: number; - hostName: string; - timestamp: string; -} - export async function getUptime(): Promise { try { const response = await authApi.get("/uptime"); diff --git a/types/index.ts b/types/index.ts index 95283bf..c405547 100644 --- a/types/index.ts +++ b/types/index.ts @@ -4,56 +4,101 @@ // This file contains all shared interfaces and types used across the application // to avoid duplication and ensure consistency. -// SSH2 types not needed for React Native +// Note: SSH2 and Express types not imported for React Native compatibility // ============================================================================ // SSH HOST TYPES // ============================================================================ +export interface JumpHost { + hostId: number; +} + +export interface QuickAction { + name: string; + snippetId: number; +} + export interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: "password" | "key" | "credential"; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - credentialId?: number; - userId?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: TunnelConnection[]; - createdAt: string; - updatedAt: string; + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: "password" | "key" | "credential" | "none"; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + forceKeyboardInteractive?: boolean; + + autostartPassword?: string; + autostartKey?: string; + autostartKeyPassword?: string; + + credentialId?: number; + overrideCredentialUsername?: boolean; + userId?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + defaultPath: string; + tunnelConnections: TunnelConnection[]; + jumpHosts?: JumpHost[]; + quickActions?: QuickAction[]; + statsConfig?: string; + terminalConfig?: TerminalConfig; + createdAt: string; + updatedAt: string; +} + +export interface JumpHostData { + hostId: number; +} + +export interface QuickActionData { + name: string; + snippetId: number; } export interface SSHHostData { - name?: string; - ip: string; - port: number; - username: string; - folder?: string; - tags?: string[]; - pin?: boolean; - authType: "password" | "key" | "credential"; - password?: string; - key?: File | null; - keyPassword?: string; - keyType?: string; - credentialId?: number | null; - enableTerminal?: boolean; - enableTunnel?: boolean; - enableFileManager?: boolean; - defaultPath?: string; - tunnelConnections?: any[]; + name?: string; + ip: string; + port: number; + username: string; + folder?: string; + tags?: string[]; + pin?: boolean; + authType: "password" | "key" | "credential" | "none"; + password?: string; + key?: File | null; + keyPassword?: string; + keyType?: string; + credentialId?: number | null; + overrideCredentialUsername?: boolean; + enableTerminal?: boolean; + enableTunnel?: boolean; + enableFileManager?: boolean; + defaultPath?: string; + forceKeyboardInteractive?: boolean; + tunnelConnections?: TunnelConnection[]; + jumpHosts?: JumpHostData[]; + quickActions?: QuickActionData[]; + statsConfig?: string | Record; + terminalConfig?: TerminalConfig; +} + +export interface SSHFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; } // ============================================================================ @@ -61,34 +106,58 @@ export interface SSHHostData { // ============================================================================ export interface Credential { - id: number; - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: "password" | "key"; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - usageCount: number; - lastUsed?: string; - createdAt: string; - updatedAt: string; + id: number; + name: string; + description?: string; + folder?: string; + tags: string[]; + authType: "password" | "key"; + username: string; + password?: string; + key?: string; + publicKey?: string; + keyPassword?: string; + keyType?: string; + usageCount: number; + lastUsed?: string; + createdAt: string; + updatedAt: string; +} + +export interface CredentialBackend { + id: number; + userId: string; + name: string; + description: string | null; + folder: string | null; + tags: string; + authType: "password" | "key"; + username: string; + password: string | null; + key: string; + private_key?: string; + public_key?: string; + key_password: string | null; + keyType?: string; + detectedKeyType: string; + usageCount: number; + lastUsed: string | null; + createdAt: string; + updatedAt: string; } export interface CredentialData { - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: "password" | "key"; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; + name: string; + description?: string; + folder?: string; + tags: string[]; + authType: "password" | "key"; + username: string; + password?: string; + key?: string; + publicKey?: string; + keyPassword?: string; + keyType?: string; } // ============================================================================ @@ -96,55 +165,62 @@ export interface CredentialData { // ============================================================================ export interface TunnelConnection { - sourcePort: number; - endpointPort: number; - endpointHost: string; - maxRetries: number; - retryInterval: number; - autoStart: boolean; + sourcePort: number; + endpointPort: number; + endpointHost: string; + + endpointPassword?: string; + endpointKey?: string; + endpointKeyPassword?: string; + endpointAuthType?: string; + endpointKeyType?: string; + + maxRetries: number; + retryInterval: number; + autoStart: boolean; } export interface TunnelConfig { - name: string; - hostName: string; - sourceIP: string; - sourceSSHPort: number; - sourceUsername: string; - sourcePassword?: string; - sourceAuthMethod: string; - sourceSSHKey?: string; - sourceKeyPassword?: string; - sourceKeyType?: string; - sourceCredentialId?: number; - sourceUserId?: string; - endpointIP: string; - endpointSSHPort: number; - endpointUsername: string; - endpointPassword?: string; - endpointAuthMethod: string; - endpointSSHKey?: string; - endpointKeyPassword?: string; - endpointKeyType?: string; - endpointCredentialId?: number; - endpointUserId?: string; - sourcePort: number; - endpointPort: number; - maxRetries: number; - retryInterval: number; - autoStart: boolean; - isPinned: boolean; + name: string; + hostName: string; + sourceIP: string; + sourceSSHPort: number; + sourceUsername: string; + sourcePassword?: string; + sourceAuthMethod: string; + sourceSSHKey?: string; + sourceKeyPassword?: string; + sourceKeyType?: string; + sourceCredentialId?: number; + sourceUserId?: string; + endpointIP: string; + endpointSSHPort: number; + endpointUsername: string; + endpointPassword?: string; + endpointAuthMethod: string; + endpointSSHKey?: string; + endpointKeyPassword?: string; + endpointKeyType?: string; + endpointCredentialId?: number; + endpointUserId?: string; + sourcePort: number; + endpointPort: number; + maxRetries: number; + retryInterval: number; + autoStart: boolean; + isPinned: boolean; } export interface TunnelStatus { - connected: boolean; - status: ConnectionState; - retryCount?: number; - maxRetries?: number; - nextRetryIn?: number; - reason?: string; - errorType?: ErrorType; - manualDisconnect?: boolean; - retryExhausted?: boolean; + connected: boolean; + status: ConnectionState; + retryCount?: number; + maxRetries?: number; + nextRetryIn?: number; + reason?: string; + errorType?: ErrorType; + manualDisconnect?: boolean; + retryExhausted?: boolean; } // ============================================================================ @@ -152,50 +228,57 @@ export interface TunnelStatus { // ============================================================================ export interface Tab { - id: string | number; - title: string; - fileName: string; - content: string; - isSSH?: boolean; - sshSessionId?: string; - filePath?: string; - loading?: boolean; - dirty?: boolean; + id: string | number; + title: string; + fileName: string; + content: string; + isSSH?: boolean; + sshSessionId?: string; + filePath?: string; + loading?: boolean; + dirty?: boolean; } export interface FileManagerFile { - name: string; - path: string; - type?: "file" | "directory"; - isSSH?: boolean; - sshSessionId?: string; + name: string; + path: string; + type?: "file" | "directory"; + isSSH?: boolean; + sshSessionId?: string; } export interface FileManagerShortcut { - name: string; - path: string; + name: string; + path: string; } export interface FileItem { - name: string; - path: string; - isPinned?: boolean; - type: "file" | "directory"; - sshSessionId?: string; + name: string; + path: string; + isPinned?: boolean; + type: "file" | "directory" | "link"; + sshSessionId?: string; + size?: number; + modified?: string; + permissions?: string; + owner?: string; + group?: string; + linkTarget?: string; + executable?: boolean; } export interface ShortcutItem { - name: string; - path: string; + name: string; + path: string; } export interface SSHConnection { - id: number; - name: string; - ip: string; - port: number; - username: string; - isPinned?: boolean; + id: number; + name: string; + ip: string; + port: number; + username: string; + isPinned?: boolean; } // ============================================================================ @@ -203,11 +286,11 @@ export interface SSHConnection { // ============================================================================ export interface HostInfo { - id: number; - name?: string; - ip: string; - port: number; - createdAt: string; + id: number; + name?: string; + ip: string; + port: number; + createdAt: string; } // ============================================================================ @@ -215,14 +298,43 @@ export interface HostInfo { // ============================================================================ export interface TermixAlert { - id: string; - title: string; - message: string; - expiresAt: string; - priority?: "low" | "medium" | "high" | "critical"; - type?: "info" | "warning" | "error" | "success"; - actionUrl?: string; - actionText?: string; + id: string; + title: string; + message: string; + expiresAt: string; + priority?: "low" | "medium" | "high" | "critical"; + type?: "info" | "warning" | "error" | "success"; + actionUrl?: string; + actionText?: string; +} + +// ============================================================================ +// TERMINAL CONFIGURATION TYPES +// ============================================================================ + +export interface TerminalConfig { + cursorBlink: boolean; + cursorStyle: "block" | "underline" | "bar"; + fontSize: number; + fontFamily: string; + letterSpacing: number; + lineHeight: number; + theme: string; + + scrollback: number; + bellStyle: "none" | "sound" | "visual" | "both"; + rightClickSelectsWord: boolean; + fastScrollModifier: "alt" | "ctrl" | "shift"; + fastScrollSensitivity: number; + minimumContrastRatio: number; + + backspaceMode: "normal" | "control-h"; + agentForwarding: boolean; + environmentVariables: Array<{ key: string; value: string }>; + startupSnippetId: number | null; + autoMosh: boolean; + moshCommand: string; + sudoPasswordAutoFill: boolean; } // ============================================================================ @@ -230,18 +342,34 @@ export interface TermixAlert { // ============================================================================ export interface TabContextTab { - id: number; - type: - | "home" - | "terminal" - | "ssh_manager" - | "server" - | "admin" - | "file_manager" - | "user_profile"; - title: string; - hostConfig?: any; - terminalRef?: React.RefObject; + id: number; + type: + | "home" + | "terminal" + | "ssh_manager" + | "server" + | "admin" + | "file_manager" + | "user_profile"; + title: string; + hostConfig?: SSHHost; + terminalRef?: any; + initialTab?: string; +} + +export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; + +export interface SplitConfiguration { + layout: SplitLayout; + positions: Map; +} + +export interface SplitLayoutOption { + id: SplitLayout; + name: string; + description: string; + cellCount: number; + icon: string; // lucide icon name } // ============================================================================ @@ -249,32 +377,32 @@ export interface TabContextTab { // ============================================================================ export const CONNECTION_STATES = { - DISCONNECTED: "disconnected", - CONNECTING: "connecting", - CONNECTED: "connected", - VERIFYING: "verifying", - FAILED: "failed", - UNSTABLE: "unstable", - RETRYING: "retrying", - WAITING: "waiting", - DISCONNECTING: "disconnecting", + DISCONNECTED: "disconnected", + CONNECTING: "connecting", + CONNECTED: "connected", + VERIFYING: "verifying", + FAILED: "failed", + UNSTABLE: "unstable", + RETRYING: "retrying", + WAITING: "waiting", + DISCONNECTING: "disconnecting", } as const; export type ConnectionState = - (typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES]; + (typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES]; export type ErrorType = - | "CONNECTION_FAILED" - | "AUTHENTICATION_FAILED" - | "TIMEOUT" - | "NETWORK_ERROR" - | "UNKNOWN"; + | "CONNECTION_FAILED" + | "AUTHENTICATION_FAILED" + | "TIMEOUT" + | "NETWORK_ERROR" + | "UNKNOWN"; // ============================================================================ // AUTHENTICATION TYPES // ============================================================================ -export type AuthType = "password" | "key" | "credential"; +export type AuthType = "password" | "key" | "credential" | "none"; export type KeyType = "rsa" | "ecdsa" | "ed25519"; @@ -282,11 +410,11 @@ export type KeyType = "rsa" | "ecdsa" | "ed25519"; // API RESPONSE TYPES // ============================================================================ -export interface ApiResponse { - data?: T; - error?: string; - message?: string; - status?: number; +export interface ApiResponse { + data?: T; + error?: string; + message?: string; + status?: number; } // ============================================================================ @@ -294,122 +422,140 @@ export interface ApiResponse { // ============================================================================ export interface CredentialsManagerProps { - onEditCredential?: (credential: Credential) => void; + onEditCredential?: (credential: Credential) => void; } export interface CredentialEditorProps { - editingCredential?: Credential | null; - onFormSubmit?: () => void; + editingCredential?: Credential | null; + onFormSubmit?: () => void; } export interface CredentialViewerProps { - credential: Credential; - onClose: () => void; - onEdit: () => void; + credential: Credential; + onClose: () => void; + onEdit: () => void; } export interface CredentialSelectorProps { - value?: number | null; - onValueChange: (value: number | null) => void; + value?: number | null; + onValueChange: (value: number | null) => void; } export interface HostManagerProps { - onSelectView?: (view: string) => void; - isTopbarOpen?: boolean; + onSelectView?: (view: string) => void; + isTopbarOpen?: boolean; + initialTab?: string; + hostConfig?: SSHHost; + rightSidebarOpen?: boolean; + rightSidebarWidth?: number; } export interface SSHManagerHostEditorProps { - editingHost?: SSHHost | null; - onFormSubmit?: () => void; + editingHost?: SSHHost | null; + onFormSubmit?: () => void; } export interface SSHManagerHostViewerProps { - onEditHost?: (host: SSHHost) => void; + onEditHost?: (host: SSHHost) => void; } export interface HostProps { - host: SSHHost; - onHostConnect?: () => void; + host: SSHHost; + onHostConnect?: () => void; } export interface SSHTunnelProps { - filterHostKey?: string; + filterHostKey?: string; } export interface SSHTunnelViewerProps { - hosts?: SSHHost[]; - tunnelStatuses?: Record; - tunnelActions?: Record< - string, - ( - action: "connect" | "disconnect" | "cancel", - host: SSHHost, - tunnelIndex: number, - ) => Promise - >; - onTunnelAction?: ( - action: "connect" | "disconnect" | "cancel", - host: SSHHost, - tunnelIndex: number, - ) => Promise; + hosts?: SSHHost[]; + tunnelStatuses?: Record; + tunnelActions?: Record< + string, + ( + action: "connect" | "disconnect" | "cancel", + host: SSHHost, + tunnelIndex: number, + ) => Promise + >; + onTunnelAction?: ( + action: "connect" | "disconnect" | "cancel", + host: SSHHost, + tunnelIndex: number, + ) => Promise; } export interface FileManagerProps { - onSelectView?: (view: string) => void; - embedded?: boolean; - initialHost?: SSHHost | null; -} - -export interface FileManagerLeftSidebarProps { - onSelectView?: (view: string) => void; - onOpenFile: (file: any) => void; - tabs: Tab[]; - host: SSHHost; - onOperationComplete?: () => void; - onError?: (error: string) => void; - onSuccess?: (message: string) => void; - onPathChange?: (path: string) => void; - onDeleteItem?: (item: any) => void; -} - -export interface FileManagerOperationsProps { - currentPath: string; - sshSessionId: string | null; - onOperationComplete?: () => void; - onError?: (error: string) => void; - onSuccess?: (message: string) => void; + onSelectView?: (view: string) => void; + embedded?: boolean; + initialHost?: SSHHost | null; } export interface AlertCardProps { - alert: TermixAlert; - onDismiss: (alertId: string) => void; + alert: TermixAlert; + onDismiss: (alertId: string) => void; } export interface AlertManagerProps { - alerts: TermixAlert[]; - onDismiss: (alertId: string) => void; - loggedIn: boolean; + alerts: TermixAlert[]; + onDismiss: (alertId: string) => void; + loggedIn: boolean; } export interface SSHTunnelObjectProps { - host: SSHHost; - tunnelStatuses: Record; - tunnelActions: Record; - onTunnelAction: ( - action: "connect" | "disconnect" | "cancel", - host: SSHHost, - tunnelIndex: number, - ) => Promise; - compact?: boolean; - bare?: boolean; + host: SSHHost; + tunnelStatuses: Record; + tunnelActions: Record; + onTunnelAction: ( + action: "connect" | "disconnect" | "cancel", + host: SSHHost, + tunnelIndex: number, + ) => Promise; + compact?: boolean; + bare?: boolean; } export interface FolderStats { - totalHosts: number; - hostsByType: Array<{ - type: string; - count: number; - }>; + totalHosts: number; + hostsByType: Array<{ + type: string; + count: number; + }>; +} + +// ============================================================================ +// SNIPPETS TYPES +// ============================================================================ + +export interface Snippet { + id: number; + userId: string; + name: string; + content: string; + description?: string; + folder?: string; + order?: number; + createdAt: string; + updatedAt: string; +} + +export interface SnippetData { + name: string; + content: string; + description?: string; + folder?: string; + order?: number; +} + +export interface SnippetFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; } // ============================================================================ @@ -417,16 +563,16 @@ export interface FolderStats { // ============================================================================ export interface HostConfig { - host: SSHHost; - tunnels: TunnelConfig[]; + host: SSHHost; + tunnels: TunnelConfig[]; } export interface VerificationData { - conn: any; - timeout: NodeJS.Timeout; - startTime: number; - attempts: number; - maxAttempts: number; + conn: any; // Client type from ssh2, using 'any' for React Native compatibility + timeout: NodeJS.Timeout; + startTime: number; + attempts: number; + maxAttempts: number; } // ============================================================================ @@ -437,4 +583,204 @@ export type Optional = Omit & Partial>; export type RequiredFields = T & Required>; -export type PartialExcept = Partial & Pick; \ No newline at end of file +export type PartialExcept = Partial & Pick; + +// ============================================================================ +// EXPRESS REQUEST TYPES (React Native compatible - no Express import) +// ============================================================================ + +export interface AuthenticatedRequest { + userId: string; + user?: { + id: string; + username: string; + isAdmin: boolean; + }; +} + +// ============================================================================ +// GITHUB API TYPES +// ============================================================================ + +export interface GitHubAsset { + id: number; + name: string; + size: number; + download_count: number; + browser_download_url: string; +} + +export interface GitHubRelease { + id: number; + tag_name: string; + name: string; + body: string; + published_at: string; + html_url: string; + assets: GitHubAsset[]; + prerelease: boolean; + draft: boolean; +} + +export interface GitHubAPIResponse { + data: T; + cached: boolean; + cache_age?: number; + timestamp?: number; +} + +// ============================================================================ +// CACHE TYPES +// ============================================================================ + +export interface CacheEntry { + data: T; + timestamp: number; + expiresAt: number; +} + +// ============================================================================ +// DATABASE EXPORT/IMPORT TYPES +// ============================================================================ + +export interface ExportSummary { + sshHostsImported: number; + sshCredentialsImported: number; + fileManagerItemsImported: number; + dismissedAlertsImported: number; + credentialUsageImported: number; + settingsImported: number; + skippedItems: number; + errors: string[]; +} + +export interface ImportResult { + success: boolean; + summary: ExportSummary; +} + +export interface ExportRequestBody { + password: string; +} + +export interface ImportRequestBody { + password: string; +} + +export interface ExportPreviewBody { + scope?: string; + includeCredentials?: boolean; +} + +export interface RestoreRequestBody { + backupPath: string; + targetPath?: string; +} + +// ============================================================================ +// SERVER METRICS TYPES +// ============================================================================ + +export interface CpuMetrics { + percent: number | null; + cores: number | null; + load: [number, number, number] | null; +} + +export interface MemoryMetrics { + percent: number | null; + usedGiB: number | null; + totalGiB: number | null; +} + +export interface DiskMetrics { + percent: number | null; + usedHuman: string | null; + totalHuman: string | null; +} + +export interface ServerMetrics { + cpu: CpuMetrics; + memory: MemoryMetrics; + disk: DiskMetrics; + lastChecked: string; +} + +export interface ServerStatus { + status: "online" | "offline"; + lastChecked: string; +} + +// ============================================================================ +// AUTH TYPES +// ============================================================================ + +export interface AuthResponse { + token: string; + success?: boolean; + is_admin?: boolean; + username?: string; + userId?: string; + is_oidc?: boolean; + totp_enabled?: boolean; + data_unlocked?: boolean; + requires_totp?: boolean; + temp_token?: string; +} + +export interface UserInfo { + totp_enabled: boolean; + userId: string; + username: string; + is_admin: boolean; + is_oidc: boolean; + data_unlocked: boolean; +} + +export interface UserCount { + count: number; +} + +export interface OIDCAuthorize { + auth_url: string; +} + +// ============================================================================ +// FILE MANAGER OPERATION TYPES +// ============================================================================ + +export interface FileManagerOperation { + name: string; + path: string; + isSSH: boolean; + sshSessionId?: string; + hostId: number; +} + +// ============================================================================ +// SERVER CONFIG TYPES +// ============================================================================ + +export interface ServerConfig { + serverUrl: string; + lastUpdated: string; +} + +// ============================================================================ +// HOMEPAGE TYPES +// ============================================================================ + +export interface UptimeInfo { + uptimeMs: number; + uptimeSeconds: number; + formatted: string; +} + +export interface RecentActivityItem { + id: number; + userId: string; + type: "terminal" | "file_manager"; + hostId: number; + hostName: string; + timestamp: string; +} From 04a6b57741928317753453dd8632566fa7b4065e Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 10 Dec 2025 00:49:46 -0600 Subject: [PATCH 11/27] feat: Clean up files, add new termainl customization/tunnel system/new server stats (local squash commit) --- app/(tabs)/hosts.tsx | 2 +- app/(tabs)/sessions.tsx | 2 +- app/(tabs)/settings.tsx | 2 +- app/_layout.tsx | 6 +- .../LoginForm.tsx | 0 .../ServerForm.tsx | 0 .../UpdateRequired.tsx | 0 app/contexts/KeyboardCustomizationContext.tsx | 2 +- app/contexts/TerminalCustomizationContext.tsx | 47 +- app/contexts/TerminalSessionsContext.tsx | 17 +- app/tabs/dialogs/SSHAuthDialog.tsx | 263 +++++++ app/tabs/dialogs/TOTPDialog.tsx | 112 +++ app/tabs/dialogs/index.ts | 2 + app/{Tabs/Hosts => tabs/hosts}/Hosts.tsx | 2 +- .../hosts/navigation}/Folder.tsx | 2 +- .../hosts/navigation}/Host.tsx | 28 + .../Sessions => tabs/sessions}/Sessions.tsx | 49 +- .../sessions/file-manager}/ContextMenu.tsx | 0 .../sessions/file-manager}/FileItem.tsx | 0 .../sessions/file-manager}/FileList.tsx | 0 .../sessions/file-manager}/FileManager.tsx | 133 ++-- .../file-manager}/FileManagerHeader.tsx | 0 .../file-manager}/FileManagerToolbar.tsx | 0 .../sessions/file-manager}/FileViewer.tsx | 0 .../sessions/file-manager}/utils/fileUtils.ts | 0 .../sessions/navigation}/TabBar.tsx | 0 .../sessions/server-stats}/ServerStats.tsx | 105 ++- .../server-stats/widgets/CpuWidget.tsx | 126 ++++ .../server-stats/widgets/DiskWidget.tsx | 93 +++ .../server-stats/widgets/LoginStatsWidget.tsx | 86 +++ .../server-stats/widgets/MemoryWidget.tsx | 93 +++ .../server-stats/widgets/NetworkWidget.tsx | 86 +++ .../server-stats/widgets/ProcessesWidget.tsx | 86 +++ .../server-stats/widgets/SystemWidget.tsx | 86 +++ .../server-stats/widgets/UptimeWidget.tsx | 86 +++ .../sessions/server-stats/widgets/index.ts | 8 + .../sessions/terminal}/Terminal.tsx | 273 ++++++- .../terminal/keyboard}/BottomToolbar.tsx | 2 +- .../terminal/keyboard}/CustomKeyboard.tsx | 2 +- .../terminal/keyboard}/KeyDefinitions.ts | 0 .../terminal/keyboard}/KeyboardBar.tsx | 2 +- .../terminal/keyboard}/KeyboardKey.tsx | 0 .../terminal/keyboard}/SnippetsBar.tsx | 2 +- app/tabs/sessions/tunnel/TunnelCard.tsx | 338 +++++++++ app/tabs/sessions/tunnel/TunnelManager.tsx | 452 +++++++++++ .../settings}/KeyboardCustomization.tsx | 2 +- .../Settings => tabs/settings}/Settings.tsx | 0 .../settings}/TerminalCustomization.tsx | 0 .../settings}/components/DraggableKeyList.tsx | 0 .../settings}/components/DraggableRowList.tsx | 0 .../settings}/components/KeySelector.tsx | 2 +- .../components/UnifiedDraggableList.tsx | 0 constants/stats-config.ts | 21 + constants/terminal-config.ts | 25 + constants/terminal-themes.ts | 710 ++++++++++++++++++ types/index.ts | 28 +- 56 files changed, 3229 insertions(+), 154 deletions(-) rename app/{Authentication => authentication}/LoginForm.tsx (100%) rename app/{Authentication => authentication}/ServerForm.tsx (100%) rename app/{Authentication => authentication}/UpdateRequired.tsx (100%) create mode 100644 app/tabs/dialogs/SSHAuthDialog.tsx create mode 100644 app/tabs/dialogs/TOTPDialog.tsx create mode 100644 app/tabs/dialogs/index.ts rename app/{Tabs/Hosts => tabs/hosts}/Hosts.tsx (99%) rename app/{Tabs/Hosts/Navigation => tabs/hosts/navigation}/Folder.tsx (98%) rename app/{Tabs/Hosts/Navigation => tabs/hosts/navigation}/Host.tsx (91%) rename app/{Tabs/Sessions => tabs/sessions}/Sessions.tsx (92%) rename app/{Tabs/Sessions/FileManager => tabs/sessions/file-manager}/ContextMenu.tsx (100%) rename app/{Tabs/Sessions/FileManager => tabs/sessions/file-manager}/FileItem.tsx (100%) rename app/{Tabs/Sessions/FileManager => tabs/sessions/file-manager}/FileList.tsx (100%) rename app/{Tabs/Sessions => tabs/sessions/file-manager}/FileManager.tsx (87%) rename app/{Tabs/Sessions/FileManager => tabs/sessions/file-manager}/FileManagerHeader.tsx (100%) rename app/{Tabs/Sessions/FileManager => tabs/sessions/file-manager}/FileManagerToolbar.tsx (100%) rename app/{Tabs/Sessions/FileManager => tabs/sessions/file-manager}/FileViewer.tsx (100%) rename app/{Tabs/Sessions/FileManager => tabs/sessions/file-manager}/utils/fileUtils.ts (100%) rename app/{Tabs/Sessions/Navigation => tabs/sessions/navigation}/TabBar.tsx (100%) rename app/{Tabs/Sessions => tabs/sessions/server-stats}/ServerStats.tsx (77%) create mode 100644 app/tabs/sessions/server-stats/widgets/CpuWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/DiskWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/SystemWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/index.ts rename app/{Tabs/Sessions => tabs/sessions/terminal}/Terminal.tsx (69%) rename app/{Tabs/Sessions => tabs/sessions/terminal/keyboard}/BottomToolbar.tsx (98%) rename app/{Tabs/Sessions => tabs/sessions/terminal/keyboard}/CustomKeyboard.tsx (99%) rename app/{Tabs/Sessions => tabs/sessions/terminal/keyboard}/KeyDefinitions.ts (100%) rename app/{Tabs/Sessions => tabs/sessions/terminal/keyboard}/KeyboardBar.tsx (98%) rename app/{Tabs/Sessions => tabs/sessions/terminal/keyboard}/KeyboardKey.tsx (100%) rename app/{Tabs/Sessions => tabs/sessions/terminal/keyboard}/SnippetsBar.tsx (99%) create mode 100644 app/tabs/sessions/tunnel/TunnelCard.tsx create mode 100644 app/tabs/sessions/tunnel/TunnelManager.tsx rename app/{Tabs/Settings => tabs/settings}/KeyboardCustomization.tsx (99%) rename app/{Tabs/Settings => tabs/settings}/Settings.tsx (100%) rename app/{Tabs/Settings => tabs/settings}/TerminalCustomization.tsx (100%) rename app/{Tabs/Settings => tabs/settings}/components/DraggableKeyList.tsx (100%) rename app/{Tabs/Settings => tabs/settings}/components/DraggableRowList.tsx (100%) rename app/{Tabs/Settings => tabs/settings}/components/KeySelector.tsx (98%) rename app/{Tabs/Settings => tabs/settings}/components/UnifiedDraggableList.tsx (100%) create mode 100644 constants/stats-config.ts create mode 100644 constants/terminal-config.ts create mode 100644 constants/terminal-themes.ts diff --git a/app/(tabs)/hosts.tsx b/app/(tabs)/hosts.tsx index 7d93d2e..fff8fde 100644 --- a/app/(tabs)/hosts.tsx +++ b/app/(tabs)/hosts.tsx @@ -1,4 +1,4 @@ -import Hosts from "@/app/Tabs/Hosts/Hosts"; +import Hosts from "@/app/tabs/hosts/Hosts"; export default function HostsScreen() { return ; diff --git a/app/(tabs)/sessions.tsx b/app/(tabs)/sessions.tsx index e6f8174..1f964ca 100644 --- a/app/(tabs)/sessions.tsx +++ b/app/(tabs)/sessions.tsx @@ -1,4 +1,4 @@ -import Sessions from "@/app/Tabs/Sessions/Sessions"; +import Sessions from "@/app/tabs/sessions/Sessions"; export default function SessionsScreen() { return ; diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 48d7a22..2a6ac06 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,4 +1,4 @@ -import Settings from "@/app/Tabs/Settings/Settings"; +import Settings from "@/app/tabs/settings/Settings"; export default function SettingsScreen() { return ; diff --git a/app/_layout.tsx b/app/_layout.tsx index 5716936..1dae430 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,14 +4,14 @@ import { TerminalSessionsProvider } from "./contexts/TerminalSessionsContext"; import { TerminalCustomizationProvider } from "./contexts/TerminalCustomizationContext"; import { KeyboardProvider } from "./contexts/KeyboardContext"; import { KeyboardCustomizationProvider } from "./contexts/KeyboardCustomizationContext"; -import ServerForm from "./Authentication/ServerForm"; -import LoginForm from "./Authentication/LoginForm"; +import ServerForm from "@/app/authentication/ServerForm"; +import LoginForm from "@/app/authentication/LoginForm"; import { View, Text, ActivityIndicator, TouchableOpacity } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { Toaster } from "sonner-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "../global.css"; -import UpdateRequired from "@/app/Authentication/UpdateRequired"; +import UpdateRequired from "@/app/authentication/UpdateRequired"; function RootLayoutContent() { const { diff --git a/app/Authentication/LoginForm.tsx b/app/authentication/LoginForm.tsx similarity index 100% rename from app/Authentication/LoginForm.tsx rename to app/authentication/LoginForm.tsx diff --git a/app/Authentication/ServerForm.tsx b/app/authentication/ServerForm.tsx similarity index 100% rename from app/Authentication/ServerForm.tsx rename to app/authentication/ServerForm.tsx diff --git a/app/Authentication/UpdateRequired.tsx b/app/authentication/UpdateRequired.tsx similarity index 100% rename from app/Authentication/UpdateRequired.tsx rename to app/authentication/UpdateRequired.tsx diff --git a/app/contexts/KeyboardCustomizationContext.tsx b/app/contexts/KeyboardCustomizationContext.tsx index 4bcbc74..f3398d3 100644 --- a/app/contexts/KeyboardCustomizationContext.tsx +++ b/app/contexts/KeyboardCustomizationContext.tsx @@ -16,7 +16,7 @@ import { import { PRESET_DEFINITIONS, getPresetById, -} from "@/app/Tabs/Sessions/KeyDefinitions"; +} from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; const STORAGE_KEY = "keyboardCustomization"; const DEFAULT_PRESET_ID: PresetType = "default"; diff --git a/app/contexts/TerminalCustomizationContext.tsx b/app/contexts/TerminalCustomizationContext.tsx index d1adb9d..14a2c61 100644 --- a/app/contexts/TerminalCustomizationContext.tsx +++ b/app/contexts/TerminalCustomizationContext.tsx @@ -6,22 +6,21 @@ import React, { useCallback, } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { TerminalConfig } from "@/types"; +import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; -const STORAGE_KEY = "terminalCustomization"; +const STORAGE_KEY = "terminalConfig"; -export interface TerminalCustomization { - fontSize: number; -} - -const getDefaultConfig = (): TerminalCustomization => { - return { - fontSize: 16, - }; +const getDefaultConfig = (): Partial => { + return MOBILE_DEFAULT_TERMINAL_CONFIG; }; interface TerminalCustomizationContextType { - config: TerminalCustomization; + config: Partial; isLoading: boolean; + updateConfig: (config: Partial) => Promise; + resetConfig: () => Promise; + // Legacy support updateFontSize: (fontSize: number) => Promise; resetToDefault: () => Promise; } @@ -34,7 +33,7 @@ export const TerminalCustomizationProvider: React.FC<{ children: React.ReactNode; }> = ({ children }) => { const [config, setConfig] = - useState(getDefaultConfig()); + useState>(getDefaultConfig()); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -42,7 +41,7 @@ export const TerminalCustomizationProvider: React.FC<{ try { const stored = await AsyncStorage.getItem(STORAGE_KEY); if (stored) { - const parsed = JSON.parse(stored) as TerminalCustomization; + const parsed = JSON.parse(stored) as Partial; setConfig(parsed); } } catch (error) { @@ -55,7 +54,7 @@ export const TerminalCustomizationProvider: React.FC<{ loadConfig(); }, []); - const saveConfig = useCallback(async (newConfig: TerminalCustomization) => { + const saveConfig = useCallback(async (newConfig: Partial) => { try { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newConfig)); setConfig(newConfig); @@ -64,24 +63,38 @@ export const TerminalCustomizationProvider: React.FC<{ } }, []); - const updateFontSize = useCallback( - async (fontSize: number) => { + const updateConfig = useCallback( + async (updates: Partial) => { const newConfig = { ...config, - fontSize, + ...updates, }; await saveConfig(newConfig); }, [config, saveConfig], ); - const resetToDefault = useCallback(async () => { + const resetConfig = useCallback(async () => { await saveConfig(getDefaultConfig()); }, [saveConfig]); + // Legacy support for existing code + const updateFontSize = useCallback( + async (fontSize: number) => { + await updateConfig({ fontSize }); + }, + [updateConfig], + ); + + const resetToDefault = useCallback(async () => { + await resetConfig(); + }, [resetConfig]); + const value: TerminalCustomizationContextType = { config, isLoading, + updateConfig, + resetConfig, updateFontSize, resetToDefault, }; diff --git a/app/contexts/TerminalSessionsContext.tsx b/app/contexts/TerminalSessionsContext.tsx index 23167b4..9c52c76 100644 --- a/app/contexts/TerminalSessionsContext.tsx +++ b/app/contexts/TerminalSessionsContext.tsx @@ -17,7 +17,7 @@ export interface TerminalSession { title: string; isActive: boolean; createdAt: Date; - type: "terminal" | "stats" | "filemanager"; + type: "terminal" | "stats" | "filemanager" | "tunnel"; } interface TerminalSessionsContextType { @@ -25,14 +25,14 @@ interface TerminalSessionsContextType { activeSessionId: string | null; addSession: ( host: SSHHost, - type?: "terminal" | "stats" | "filemanager", + type?: "terminal" | "stats" | "filemanager" | "tunnel", ) => string; removeSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void; clearAllSessions: () => void; navigateToSessions: ( host?: SSHHost, - type?: "terminal" | "stats" | "filemanager", + type?: "terminal" | "stats" | "filemanager" | "tunnel", ) => void; isCustomKeyboardVisible: boolean; toggleCustomKeyboard: () => void; @@ -74,7 +74,7 @@ export const TerminalSessionsProvider: React.FC< const addSession = useCallback( ( host: SSHHost, - type: "terminal" | "stats" | "filemanager" = "terminal", + type: "terminal" | "stats" | "filemanager" | "tunnel" = "terminal", ): string => { setSessions((prev) => { const existingSessions = prev.filter( @@ -82,7 +82,10 @@ export const TerminalSessionsProvider: React.FC< ); const typeLabel = - type === "stats" ? "Stats" : type === "filemanager" ? "Files" : ""; + type === "stats" ? "Stats" + : type === "filemanager" ? "Files" + : type === "tunnel" ? "Tunnels" + : ""; let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; if (existingSessions.length > 0) { title = typeLabel @@ -148,6 +151,8 @@ export const TerminalSessionsProvider: React.FC< ? "Stats" : session.type === "filemanager" ? "Files" + : session.type === "tunnel" + ? "Tunnels" : ""; const baseName = typeLabel ? `${session.host.name} - ${typeLabel}` @@ -202,7 +207,7 @@ export const TerminalSessionsProvider: React.FC< const navigateToSessions = useCallback( ( host?: SSHHost, - type: "terminal" | "stats" | "filemanager" = "terminal", + type: "terminal" | "stats" | "filemanager" | "tunnel" = "terminal", ) => { if (host) { addSession(host, type); diff --git a/app/tabs/dialogs/SSHAuthDialog.tsx b/app/tabs/dialogs/SSHAuthDialog.tsx new file mode 100644 index 0000000..141be33 --- /dev/null +++ b/app/tabs/dialogs/SSHAuthDialog.tsx @@ -0,0 +1,263 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, TextInput, TouchableOpacity, Modal, ScrollView } from 'react-native'; +import { BORDERS, BORDER_COLORS, RADIUS } from '@/app/constants/designTokens'; + +interface SSHAuthDialogProps { + visible: boolean; + onSubmit: (credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) => void; + onCancel: () => void; + hostInfo: { + name?: string; + ip: string; + port: number; + username: string; + }; + reason: 'no_keyboard' | 'auth_failed' | 'timeout'; +} + +export const SSHAuthDialog: React.FC = ({ + visible, + onSubmit, + onCancel, + hostInfo, + reason, +}) => { + const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password'); + const [password, setPassword] = useState(''); + const [sshKey, setSshKey] = useState(''); + const [keyPassword, setKeyPassword] = useState(''); + + // Clear inputs when dialog closes + useEffect(() => { + if (!visible) { + setPassword(''); + setSshKey(''); + setKeyPassword(''); + setAuthMethod('password'); + } + }, [visible]); + + const getReasonMessage = () => { + switch (reason) { + case 'no_keyboard': + return 'Keyboard-interactive authentication is not supported on mobile. Please provide credentials directly.'; + case 'auth_failed': + return 'Authentication failed. Please re-enter your credentials.'; + case 'timeout': + return 'Connection timed out. Please try again with your credentials.'; + default: + return 'Please provide your credentials to connect.'; + } + }; + + const handleSubmit = () => { + if (authMethod === 'password' && password.trim()) { + onSubmit({ password }); + setPassword(''); + } else if (authMethod === 'key' && sshKey.trim()) { + onSubmit({ + sshKey, + keyPassword: keyPassword.trim() || undefined, + }); + setSshKey(''); + setKeyPassword(''); + } + }; + + const handleCancel = () => { + setPassword(''); + setSshKey(''); + setKeyPassword(''); + onCancel(); + }; + + const isValid = authMethod === 'password' ? password.trim() : sshKey.trim(); + + return ( + + + + + + SSH Authentication Required + + + {/* Host Info */} + + + {hostInfo.name && ( + {hostInfo.name} + )} + {hostInfo.name && '\n'} + + {hostInfo.username}@{hostInfo.ip}:{hostInfo.port} + + + + + {/* Reason Message */} + + + {getReasonMessage()} + + + + {/* Auth Method Selector */} + + setAuthMethod('password')} + className={`flex-1 py-2 ${ + authMethod === 'password' ? 'bg-blue-500' : 'bg-dark-bg-darker' + }`} + style={{ + borderWidth: BORDERS.STANDARD, + borderColor: authMethod === 'password' ? '#2563EB' : BORDER_COLORS.BUTTON, + borderRadius: RADIUS.BUTTON, + }} + activeOpacity={0.7} + > + + Password + + + setAuthMethod('key')} + className={`flex-1 py-2 ${ + authMethod === 'key' ? 'bg-blue-500' : 'bg-dark-bg-darker' + }`} + style={{ + borderWidth: BORDERS.STANDARD, + borderColor: authMethod === 'key' ? '#2563EB' : BORDER_COLORS.BUTTON, + borderRadius: RADIUS.BUTTON, + }} + activeOpacity={0.7} + > + + SSH Key + + + + + {/* Password Input */} + {authMethod === 'password' && ( + + Password + + + )} + + {/* SSH Key Inputs */} + {authMethod === 'key' && ( + <> + + Private SSH Key + + + + + Key Password (optional) + + + + + )} + + {/* Action Buttons */} + + + + Cancel + + + + + Connect + + + + + + + + ); +}; diff --git a/app/tabs/dialogs/TOTPDialog.tsx b/app/tabs/dialogs/TOTPDialog.tsx new file mode 100644 index 0000000..3694616 --- /dev/null +++ b/app/tabs/dialogs/TOTPDialog.tsx @@ -0,0 +1,112 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, TextInput, TouchableOpacity, Modal } from 'react-native'; +import { BORDERS, BORDER_COLORS, RADIUS } from '@/app/constants/designTokens'; + +interface TOTPDialogProps { + visible: boolean; + onSubmit: (code: string) => void; + onCancel: () => void; + prompt?: string; + isPasswordPrompt?: boolean; +} + +export const TOTPDialog: React.FC = ({ + visible, + onSubmit, + onCancel, + prompt = 'Two-Factor Authentication', + isPasswordPrompt = false, +}) => { + const [code, setCode] = useState(''); + + // Clear code when dialog closes + useEffect(() => { + if (!visible) { + setCode(''); + } + }, [visible]); + + const handleSubmit = () => { + if (code.trim()) { + onSubmit(code); + setCode(''); + } + }; + + const handleCancel = () => { + setCode(''); + onCancel(); + }; + + return ( + + + + + {prompt} + + + {isPasswordPrompt + ? 'Enter your password to continue' + : 'Enter your TOTP verification code'} + + + + + + Cancel + + + + + {isPasswordPrompt ? 'Submit' : 'Verify'} + + + + + + + ); +}; diff --git a/app/tabs/dialogs/index.ts b/app/tabs/dialogs/index.ts new file mode 100644 index 0000000..39fca0c --- /dev/null +++ b/app/tabs/dialogs/index.ts @@ -0,0 +1,2 @@ +export { TOTPDialog } from './TOTPDialog'; +export { SSHAuthDialog } from './SSHAuthDialog'; diff --git a/app/Tabs/Hosts/Hosts.tsx b/app/tabs/hosts/Hosts.tsx similarity index 99% rename from app/Tabs/Hosts/Hosts.tsx rename to app/tabs/hosts/Hosts.tsx index 6b68be1..7349abb 100644 --- a/app/Tabs/Hosts/Hosts.tsx +++ b/app/tabs/hosts/Hosts.tsx @@ -11,7 +11,7 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useState, useEffect, useCallback, useRef } from "react"; import { RefreshCw } from "lucide-react-native"; -import Folder from "@/app/Tabs/Hosts/Navigation/Folder"; +import Folder from "@/app/tabs/hosts/navigation/Folder"; import { getSSHHosts, getFoldersWithStats, diff --git a/app/Tabs/Hosts/Navigation/Folder.tsx b/app/tabs/hosts/navigation/Folder.tsx similarity index 98% rename from app/Tabs/Hosts/Navigation/Folder.tsx rename to app/tabs/hosts/navigation/Folder.tsx index e68ea53..f47802e 100644 --- a/app/Tabs/Hosts/Navigation/Folder.tsx +++ b/app/tabs/hosts/navigation/Folder.tsx @@ -1,5 +1,5 @@ import { View, Text, TouchableOpacity, Animated } from "react-native"; -import Host from "@/app/Tabs/Hosts/Navigation/Host"; +import Host from "@/app/tabs/hosts/navigation/Host"; import { ChevronDown } from "lucide-react-native"; import { useState, useRef, useEffect } from "react"; import { SSHHost } from "@/types"; diff --git a/app/Tabs/Hosts/Navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx similarity index 91% rename from app/Tabs/Hosts/Navigation/Host.tsx rename to app/tabs/hosts/navigation/Host.tsx index e50af46..6b88531 100644 --- a/app/Tabs/Hosts/Navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -399,6 +399,34 @@ function Host({ host, status, isLast = false }: HostProps) { )} + {host.enableTunnel && + host.tunnelConnections && + host.tunnelConnections.length > 0 && ( + { + navigateToSessions(host, "tunnel"); + setShowContextMenu(false); + }} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + + + Manage Tunnels + + + {host.tunnelConnections.length} tunnel + {host.tunnelConnections.length !== 1 ? "s" : ""}{" "} + configured + + + + )} + > >({}); + const tunnelManagerRefs = useRef< + Record> + >({}); const [activeModifiers, setActiveModifiers] = useState({ ctrl: false, alt: false, @@ -77,6 +84,7 @@ export default function Sessions() { ); const [keyboardType, setKeyboardType] = useState("default"); const lastBlurTimeRef = useRef(0); + const [terminalBackgroundColors, setTerminalBackgroundColors] = useState>({}); const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); const effectiveKeyboardHeight = isLandscape @@ -368,6 +376,10 @@ export default function Sessions() { (session) => session.id === activeSessionId, ); + const activeTerminalBgColor = activeSession?.type === "terminal" && activeSessionId + ? terminalBackgroundColors[activeSessionId] || BACKGROUNDS.DARKEST + : BACKGROUNDS.DARKEST; + return ( handleTabClose(session.id)} + onBackgroundColorChange={(color) => { + setTerminalBackgroundColors((prev) => ({ + ...prev, + [session.id]: color, + })); + }} /> ); } else if (session.type === "stats") { @@ -437,6 +456,22 @@ export default function Sessions() { isVisible={session.id === activeSessionId} /> ); + } else if (session.type === "tunnel") { + return ( + handleTabClose(session.id)} + /> + ); } return null; })} diff --git a/app/Tabs/Sessions/FileManager/ContextMenu.tsx b/app/tabs/sessions/file-manager/ContextMenu.tsx similarity index 100% rename from app/Tabs/Sessions/FileManager/ContextMenu.tsx rename to app/tabs/sessions/file-manager/ContextMenu.tsx diff --git a/app/Tabs/Sessions/FileManager/FileItem.tsx b/app/tabs/sessions/file-manager/FileItem.tsx similarity index 100% rename from app/Tabs/Sessions/FileManager/FileItem.tsx rename to app/tabs/sessions/file-manager/FileItem.tsx diff --git a/app/Tabs/Sessions/FileManager/FileList.tsx b/app/tabs/sessions/file-manager/FileList.tsx similarity index 100% rename from app/Tabs/Sessions/FileManager/FileList.tsx rename to app/tabs/sessions/file-manager/FileList.tsx diff --git a/app/Tabs/Sessions/FileManager.tsx b/app/tabs/sessions/file-manager/FileManager.tsx similarity index 87% rename from app/Tabs/Sessions/FileManager.tsx rename to app/tabs/sessions/file-manager/FileManager.tsx index d75ace5..9d1a5e5 100644 --- a/app/Tabs/Sessions/FileManager.tsx +++ b/app/tabs/sessions/file-manager/FileManager.tsx @@ -18,6 +18,7 @@ import { Platform, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Server } from "lucide-react-native"; import { SSHHost } from "@/types"; import { useOrientation } from "@/app/utils/orientation"; import { getResponsivePadding, getTabBarHeight } from "@/app/utils/responsive"; @@ -42,17 +43,18 @@ import { keepSSHAlive, identifySSHSymlink, } from "@/app/main-axios"; -import { FileList } from "./FileManager/FileList"; -import { FileManagerHeader } from "./FileManager/FileManagerHeader"; -import { FileManagerToolbar } from "./FileManager/FileManagerToolbar"; -import { ContextMenu } from "./FileManager/ContextMenu"; -import { FileViewer } from "./FileManager/FileViewer"; +import { FileList } from "@/app/tabs/sessions/file-manager/FileList"; +import { FileManagerHeader } from "@/app/tabs/sessions/file-manager/FileManagerHeader"; +import { FileManagerToolbar } from "@/app/tabs/sessions/file-manager/FileManagerToolbar"; +import { ContextMenu } from "@/app/tabs/sessions/file-manager/ContextMenu"; +import { FileViewer } from "@/app/tabs/sessions/file-manager/FileViewer"; import { joinPath, isTextFile, isArchiveFile, -} from "./FileManager/utils/fileUtils"; +} from "@/app/tabs/sessions/file-manager/utils/fileUtils"; import { showToast } from "@/app/utils/toast"; +import { TOTPDialog } from "@/app/tabs/dialogs"; interface FileManagerProps { host: SSHHost; @@ -150,9 +152,9 @@ export const FileManager = forwardRef( } }, [host, sessionId]); - const handleTOTPVerify = async () => { + const handleTOTPVerify = async (code: string) => { try { - await verifySSHTOTP(sessionId, totpCode); + await verifySSHTOTP(sessionId, code); setTotpDialog(false); setTotpCode(""); setSshSessionId(sessionId); @@ -418,79 +420,60 @@ export const FileManager = forwardRef( }, })); + // Check if file manager is disabled + if (!host.enableFileManager) { + return ( + + + + File Manager Disabled + + + File Manager is not enabled for this host. Contact your + administrator to enable it. + + + ); + } + if (!isConnected) { return ( Connecting to {host.name}... - - - - - Two-Factor Authentication - - - Enter your TOTP code to continue - - - - { - setTotpDialog(false); - setTotpCode(""); - }} - className="flex-1 bg-dark-bg-darker py-3" - style={{ - borderWidth: BORDERS.STANDARD, - borderColor: BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} - > - - Cancel - - - - - Verify - - - - - - + { + setTotpDialog(false); + setTotpCode(""); + }} + prompt="Two-Factor Authentication" + isPasswordPrompt={false} + /> ); } diff --git a/app/Tabs/Sessions/FileManager/FileManagerHeader.tsx b/app/tabs/sessions/file-manager/FileManagerHeader.tsx similarity index 100% rename from app/Tabs/Sessions/FileManager/FileManagerHeader.tsx rename to app/tabs/sessions/file-manager/FileManagerHeader.tsx diff --git a/app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx b/app/tabs/sessions/file-manager/FileManagerToolbar.tsx similarity index 100% rename from app/Tabs/Sessions/FileManager/FileManagerToolbar.tsx rename to app/tabs/sessions/file-manager/FileManagerToolbar.tsx diff --git a/app/Tabs/Sessions/FileManager/FileViewer.tsx b/app/tabs/sessions/file-manager/FileViewer.tsx similarity index 100% rename from app/Tabs/Sessions/FileManager/FileViewer.tsx rename to app/tabs/sessions/file-manager/FileViewer.tsx diff --git a/app/Tabs/Sessions/FileManager/utils/fileUtils.ts b/app/tabs/sessions/file-manager/utils/fileUtils.ts similarity index 100% rename from app/Tabs/Sessions/FileManager/utils/fileUtils.ts rename to app/tabs/sessions/file-manager/utils/fileUtils.ts diff --git a/app/Tabs/Sessions/Navigation/TabBar.tsx b/app/tabs/sessions/navigation/TabBar.tsx similarity index 100% rename from app/Tabs/Sessions/Navigation/TabBar.tsx rename to app/tabs/sessions/navigation/TabBar.tsx diff --git a/app/Tabs/Sessions/ServerStats.tsx b/app/tabs/sessions/server-stats/ServerStats.tsx similarity index 77% rename from app/Tabs/Sessions/ServerStats.tsx rename to app/tabs/sessions/server-stats/ServerStats.tsx index de7baff..09d9a48 100644 --- a/app/Tabs/Sessions/ServerStats.tsx +++ b/app/tabs/sessions/server-stats/ServerStats.tsx @@ -23,9 +23,9 @@ import { Clock, Server, } from "lucide-react-native"; -import { getServerMetricsById } from "../../main-axios"; -import { showToast } from "../../utils/toast"; -import type { ServerMetrics } from "../../../types/index"; +import { getServerMetricsById, executeSnippet } from "../../../main-axios"; +import { showToast } from "../../../utils/toast"; +import type { ServerMetrics, QuickAction } from "../../../../types"; import { useOrientation } from "@/app/utils/orientation"; import { getResponsivePadding, @@ -43,6 +43,7 @@ interface ServerStatsProps { hostConfig: { id: number; name: string; + quickActions?: QuickAction[]; }; isVisible: boolean; title?: string; @@ -61,6 +62,9 @@ export const ServerStats = forwardRef( const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); + const [executingActions, setExecutingActions] = useState>( + new Set() + ); const refreshIntervalRef = useRef(null); const padding = getResponsivePadding(isLandscape); @@ -144,6 +148,35 @@ export const ServerStats = forwardRef( } }; + const handleQuickAction = async (action: QuickAction) => { + setExecutingActions((prev) => new Set(prev).add(action.snippetId)); + showToast.loading(`Executing ${action.name}...`); + + try { + const result = await executeSnippet(action.snippetId, hostConfig.id); + + if (result.success) { + showToast.success(`${action.name} completed`, { + description: result.output?.substring(0, 200), + }); + } else { + showToast.error(`${action.name} failed`, { + description: result.error || result.output, + }); + } + } catch (error: any) { + showToast.error(`${action.name} error`, { + description: error?.message || "Unknown error", + }); + } finally { + setExecutingActions((prev) => { + const next = new Set(prev); + next.delete(action.snippetId); + return next; + }); + } + }; + const renderMetricCard = ( icon: React.ReactNode, title: string, @@ -306,6 +339,72 @@ export const ServerStats = forwardRef( + {/* Quick Actions Section */} + {hostConfig?.quickActions && + hostConfig.quickActions.length > 0 && ( + + + Quick Actions + + + {hostConfig.quickActions.map((action) => { + const isExecuting = executingActions.has( + action.snippetId + ); + return ( + handleQuickAction(action)} + disabled={isExecuting} + style={{ + backgroundColor: isExecuting + ? "#374151" + : "#22C55E", + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: RADIUS.BUTTON, + flexDirection: "row", + alignItems: "center", + gap: 8, + opacity: isExecuting ? 0.6 : 1, + }} + activeOpacity={0.7} + > + {isExecuting && ( + + )} + + {action.name} + + + ); + })} + + + )} + = ({ metrics, isLoading }) => { + const cpuPercent = metrics?.cpu?.percent ?? null; + const cores = metrics?.cpu?.cores ?? null; + const load = metrics?.cpu?.load ?? null; + + return ( + + + + CPU Usage + + + + + {cpuPercent !== null ? `${cpuPercent.toFixed(1)}%` : 'N/A'} + + + {cores !== null ? `${cores} cores` : 'N/A'} + + + + {load && ( + + + {load[0].toFixed(2)} + 1m + + + {load[1].toFixed(2)} + 5m + + + {load[2].toFixed(2)} + 15m + + + )} + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: 'relative', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + color: '#9CA3AF', + fontSize: 14, + }, + loadRow: { + flexDirection: 'row', + justifyContent: 'space-around', + marginTop: 8, + }, + loadItem: { + alignItems: 'center', + }, + loadValue: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + loadLabel: { + color: '#9CA3AF', + fontSize: 12, + marginTop: 2, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx new file mode 100644 index 0000000..f33f1d0 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { HardDrive } from 'lucide-react-native'; +import { ServerMetrics } from '@/types'; +import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const DiskWidget: React.FC = ({ metrics, isLoading }) => { + const diskPercent = metrics?.disk?.percent ?? null; + const usedHuman = metrics?.disk?.usedHuman ?? null; + const totalHuman = metrics?.disk?.totalHuman ?? null; + + return ( + + + + Disk Usage + + + + + {diskPercent !== null ? `${diskPercent.toFixed(1)}%` : 'N/A'} + + + {usedHuman !== null && totalHuman !== null + ? `${usedHuman} / ${totalHuman}` + : 'N/A'} + + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: 'relative', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + color: '#9CA3AF', + fontSize: 14, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx b/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx new file mode 100644 index 0000000..dde2a0a --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { Users } from 'lucide-react-native'; +import { ServerMetrics } from '@/types'; +import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const LoginStatsWidget: React.FC = ({ metrics, isLoading }) => { + // Login stats not yet in ServerMetrics type + // Will show N/A until backend provides this data + + return ( + + + + Login Stats + + + + N/A + Not available yet + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: 'relative', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + color: '#9CA3AF', + fontSize: 14, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx new file mode 100644 index 0000000..9705776 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { MemoryStick } from 'lucide-react-native'; +import { ServerMetrics } from '@/types'; +import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const MemoryWidget: React.FC = ({ metrics, isLoading }) => { + const memoryPercent = metrics?.memory?.percent ?? null; + const usedGiB = metrics?.memory?.usedGiB ?? null; + const totalGiB = metrics?.memory?.totalGiB ?? null; + + return ( + + + + Memory Usage + + + + + {memoryPercent !== null ? `${memoryPercent.toFixed(1)}%` : 'N/A'} + + + {usedGiB !== null && totalGiB !== null + ? `${usedGiB.toFixed(2)} / ${totalGiB.toFixed(2)} GiB` + : 'N/A'} + + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: 'relative', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + color: '#9CA3AF', + fontSize: 14, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx b/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx new file mode 100644 index 0000000..f2a6023 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { Network } from 'lucide-react-native'; +import { ServerMetrics } from '@/types'; +import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const NetworkWidget: React.FC = ({ metrics, isLoading }) => { + // Network metrics not yet in ServerMetrics type + // Will show N/A until backend provides this data + + return ( + + + + Network + + + + N/A + Not available yet + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: 'relative', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + color: '#9CA3AF', + fontSize: 14, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx b/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx new file mode 100644 index 0000000..6e24b0e --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { Activity } from 'lucide-react-native'; +import { ServerMetrics } from '@/types'; +import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const ProcessesWidget: React.FC = ({ metrics, isLoading }) => { + // Process metrics not yet in ServerMetrics type + // Will show N/A until backend provides this data + + return ( + + + + Processes + + + + N/A + Not available yet + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: 'relative', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + color: '#9CA3AF', + fontSize: 14, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx b/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx new file mode 100644 index 0000000..4d4647f --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { Server } from 'lucide-react-native'; +import { ServerMetrics } from '@/types'; +import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const SystemWidget: React.FC = ({ metrics, isLoading }) => { + // System info not yet in ServerMetrics type + // Will show N/A until backend provides this data + + return ( + + + + System Info + + + + N/A + Not available yet + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: 'relative', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + color: '#9CA3AF', + fontSize: 14, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx b/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx new file mode 100644 index 0000000..ba46d72 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { Clock } from 'lucide-react-native'; +import { ServerMetrics } from '@/types'; +import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const UptimeWidget: React.FC = ({ metrics, isLoading }) => { + // Uptime metrics not yet in ServerMetrics type + // Will show N/A until backend provides this data + + return ( + + + + System Uptime + + + + N/A + Not available yet + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: 'relative', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + title: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + color: '#9CA3AF', + fontSize: 14, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/index.ts b/app/tabs/sessions/server-stats/widgets/index.ts new file mode 100644 index 0000000..5b4c0ac --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/index.ts @@ -0,0 +1,8 @@ +export { CpuWidget } from './CpuWidget'; +export { MemoryWidget } from './MemoryWidget'; +export { DiskWidget } from './DiskWidget'; +export { NetworkWidget } from './NetworkWidget'; +export { UptimeWidget } from './UptimeWidget'; +export { ProcessesWidget } from './ProcessesWidget'; +export { SystemWidget } from './SystemWidget'; +export { LoginStatsWidget } from './LoginStatsWidget'; diff --git a/app/Tabs/Sessions/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx similarity index 69% rename from app/Tabs/Sessions/Terminal.tsx rename to app/tabs/sessions/terminal/Terminal.tsx index 08888c2..3a9905d 100644 --- a/app/Tabs/Sessions/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -16,10 +16,14 @@ import { TextInput, } from "react-native"; import { WebView } from "react-native-webview"; -import { getCurrentServerUrl, getCookie, logActivity } from "../../main-axios"; -import { showToast } from "../../utils/toast"; -import { useTerminalCustomization } from "../../contexts/TerminalCustomizationContext"; -import { BACKGROUNDS, BORDER_COLORS } from "../../constants/designTokens"; +import { getCurrentServerUrl, getCookie, logActivity, getSnippets } from "../../../main-axios"; +import { showToast } from "../../../utils/toast"; +import { useTerminalCustomization } from "../../../contexts/TerminalCustomizationContext"; +import { BACKGROUNDS, BORDER_COLORS } from "../../../constants/designTokens"; +import { TOTPDialog, SSHAuthDialog } from "@/app/tabs/dialogs"; +import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes"; +import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; +import type { TerminalConfig } from "@/types"; interface TerminalProps { hostConfig: { @@ -34,10 +38,12 @@ interface TerminalProps { keyPassword?: string; keyType?: string; credentialId?: number; + terminalConfig?: Partial; }; isVisible: boolean; title?: string; onClose?: () => void; + onBackgroundColorChange?: (color: string) => void; } export type TerminalHandle = { @@ -46,7 +52,7 @@ export type TerminalHandle = { }; const TerminalComponent = forwardRef( - ({ hostConfig, isVisible, title = "Terminal", onClose }, ref) => { + ({ hostConfig, isVisible, title = "Terminal", onClose, onBackgroundColorChange }, ref) => { const webViewRef = useRef(null); const { config } = useTerminalCustomization(); const [webViewKey, setWebViewKey] = useState(0); @@ -61,10 +67,20 @@ const TerminalComponent = forwardRef( const [showConnectingOverlay, setShowConnectingOverlay] = useState(true); const [htmlContent, setHtmlContent] = useState(""); const [currentHostId, setCurrentHostId] = useState(null); + const [terminalBackgroundColor, setTerminalBackgroundColor] = useState("#09090b"); const connectionTimeoutRef = useRef | null>( null, ); + // TOTP and Auth dialog state + 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" + >("auth_failed"); + useEffect(() => { const subscription = Dimensions.addEventListener( "change", @@ -133,12 +149,38 @@ const TerminalComponent = forwardRef( `; } - const baseFontSize = config.fontSize; + // Merge terminal config (host config > global config > defaults) + const terminalConfig: Partial = { + ...MOBILE_DEFAULT_TERMINAL_CONFIG, + ...config, + ...hostConfig.terminalConfig, + }; + + // Use user's custom fontSize from context, not from API + const baseFontSize = config.fontSize || 16; const charWidth = baseFontSize * 0.6; const lineHeight = baseFontSize * 1.2; const terminalWidth = Math.floor(width / charWidth); const terminalHeight = Math.floor(height / lineHeight); + // Get theme colors + const themeName = terminalConfig.theme || "termix"; + const themeColors = TERMINAL_THEMES[themeName]?.colors || TERMINAL_THEMES.termix.colors; + + // Update background color state and notify parent + const bgColor = themeColors.background; + setTerminalBackgroundColor(bgColor); + if (onBackgroundColorChange) { + onBackgroundColorChange(bgColor); + } + + // Get font family + const fontConfig = TERMINAL_FONTS.find( + (f) => f.value === terminalConfig.fontFamily + ); + const fontFamily = + fontConfig?.fallback || TERMINAL_FONTS[0].fallback; + return ` @@ -185,8 +227,8 @@ const TerminalComponent = forwardRef( body { margin: 0; padding: 0; - background-color: #09090b; - font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + background-color: ${themeColors.background}; + font-family: ${fontFamily}; overflow: hidden; width: 100vw; height: 100vh; @@ -273,18 +315,38 @@ const TerminalComponent = forwardRef( const screenHeight = ${height}; const baseFontSize = ${baseFontSize}; - + const terminal = new Terminal({ - cursorBlink: false, - cursorStyle: 'bar', - scrollback: 10000, + cursorBlink: ${terminalConfig.cursorBlink || false}, + cursorStyle: '${terminalConfig.cursorStyle || "bar"}', + scrollback: ${terminalConfig.scrollback || 10000}, fontSize: baseFontSize, - fontFamily: '"Caskaydia Cove Nerd Font Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontFamily: ${JSON.stringify(fontFamily)}, + letterSpacing: ${terminalConfig.letterSpacing || 0}, + lineHeight: ${terminalConfig.lineHeight || 1.2}, theme: { - background: '#09090b', - foreground: '#f7f7f7', - cursor: '#f7f7f7', - selection: 'rgba(255, 255, 255, 0.3)' + background: '${themeColors.background}', + foreground: '${themeColors.foreground}', + cursor: '${themeColors.cursor || themeColors.foreground}', + cursorAccent: '${themeColors.cursorAccent || themeColors.background}', + selectionBackground: '${themeColors.selectionBackground || "rgba(255, 255, 255, 0.3)"}', + 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}' }, allowTransparency: true, convertEol: true, @@ -296,7 +358,7 @@ const TerminalComponent = forwardRef( fastScrollSensitivity: 5, allowProposedApi: true, disableStdin: true, - cursorInactiveStyle: 'bar' + cursorInactiveStyle: '${terminalConfig.cursorStyle || "bar"}' }); const fitAddon = new FitAddon.FitAddon(); @@ -433,10 +495,28 @@ const TerminalComponent = forwardRef( ws.onmessage = function(event) { try { const msg = JSON.parse(event.data); - + if (msg.type === 'data') { terminal.write(msg.data); notifyConnectionState('dataReceived', { hostName: hostConfig.name }); + } else if (msg.type === 'totp_required') { + notifyConnectionState('totpRequired', { + prompt: msg.prompt || 'Verification code:', + isPassword: false + }); + } else if (msg.type === 'password_required') { + notifyConnectionState('totpRequired', { + prompt: msg.prompt || 'Password:', + isPassword: true + }); + } else if (msg.type === 'keyboard_interactive_available') { + notifyConnectionState('authDialogNeeded', { + reason: 'no_keyboard' + }); + } else if (msg.type === 'auth_method_not_available') { + notifyConnectionState('authDialogNeeded', { + reason: 'no_keyboard' + }); } else if (msg.type === 'error') { const message = msg.message || 'Unknown error'; if (isUnrecoverableError(message)) { @@ -446,6 +526,8 @@ const TerminalComponent = forwardRef( return; } } else if (msg.type === 'connected') { + // Post-connection setup: inject env vars and startup snippet + notifyConnectionState('setupPostConnection', {}); } else if (msg.type === 'disconnected') { notifyConnectionState('disconnected', { hostName: hostConfig.name }); } else if (msg.type === 'pong') { @@ -558,6 +640,102 @@ const TerminalComponent = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const handleTotpSubmit = useCallback((code: string) => { + webViewRef.current?.injectJavaScript(` + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: '${isPasswordPrompt ? "password_response" : "totp_response"}', + data: { code: '${code.replace(/'/g, "\\'")}' } + })); + } + true; + `); + setTotpRequired(false); + setTotpPrompt(""); + setIsPasswordPrompt(false); + }, [isPasswordPrompt]); + + const handleAuthDialogSubmit = useCallback((credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) => { + const password = credentials.password?.replace(/'/g, "\\'") || ""; + const sshKey = credentials.sshKey?.replace(/'/g, "\\'") || ""; + const keyPassword = credentials.keyPassword?.replace(/'/g, "\\'") || ""; + + webViewRef.current?.injectJavaScript(` + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: 'reconnect_with_credentials', + data: { + password: ${credentials.password ? `'${password}'` : "undefined"}, + sshKey: ${credentials.sshKey ? `'${sshKey}'` : "undefined"}, + keyPassword: ${credentials.keyPassword ? `'${keyPassword}'` : "undefined"} + } + })); + } + true; + `); + setShowAuthDialog(false); + setIsConnecting(true); + }, []); + + const handlePostConnectionSetup = useCallback(async () => { + const terminalConfig: Partial = { + ...MOBILE_DEFAULT_TERMINAL_CONFIG, + ...config, + ...hostConfig.terminalConfig, + }; + + // Wait for terminal to be ready + setTimeout(async () => { + // Inject environment variables + if (terminalConfig.environmentVariables?.length) { + terminalConfig.environmentVariables.forEach((envVar, index) => { + setTimeout(() => { + const key = envVar.key.replace(/'/g, "\\'"); + const value = envVar.value.replace(/'/g, "\\'"); + webViewRef.current?.injectJavaScript(` + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: 'input', + data: 'export ${key}="${value}"\\n' + })); + } + true; + `); + }, 100 * (index + 1)); + }); + } + + // Execute startup snippet + if (terminalConfig.startupSnippetId) { + const snippetDelay = 100 * (terminalConfig.environmentVariables?.length || 0) + 200; + setTimeout(async () => { + try { + const snippets = await getSnippets(); + const snippet = snippets.find(s => s.id === terminalConfig.startupSnippetId); + if (snippet) { + const content = snippet.content.replace(/'/g, "\\'"); + webViewRef.current?.injectJavaScript(` + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: 'input', + data: '${content}\\n' + })); + } + true; + `); + } + } catch (err) { + console.warn("Failed to execute startup snippet:", err); + } + }, snippetDelay); + } + }, 500); + }, [config, hostConfig.terminalConfig]); + const handleWebViewMessage = useCallback( (event: any) => { try { @@ -587,6 +765,22 @@ const TerminalComponent = forwardRef( ); break; + case "totpRequired": + setTotpPrompt(message.data.prompt); + setIsPasswordPrompt(message.data.isPassword); + setTotpRequired(true); + break; + + case "authDialogNeeded": + setAuthDialogReason(message.data.reason); + setShowAuthDialog(true); + setIsConnecting(false); + break; + + case "setupPostConnection": + handlePostConnectionSetup(); + break; + case "dataReceived": setHasReceivedData(true); setShowConnectingOverlay(false); @@ -612,7 +806,7 @@ const TerminalComponent = forwardRef( } } catch (error) {} }, - [handleConnectionFailure, onClose, hostConfig.id], + [handleConnectionFailure, onClose, hostConfig.id, handlePostConnectionSetup], ); useImperativeHandle( @@ -677,6 +871,7 @@ const TerminalComponent = forwardRef( left: isVisible ? 0 : 0, right: isVisible ? 0 : 0, bottom: isVisible ? 0 : 0, + backgroundColor: terminalBackgroundColor, }} > ( opacity: isVisible ? 1 : 0, position: "relative", zIndex: isVisible ? 1 : -1, + backgroundColor: terminalBackgroundColor, }} > - + {/* Note: Hidden TextInput removed - keyboard handled by Sessions.tsx */} ( flex: 1, width: "100%", height: "100%", - backgroundColor: "#09090b", + backgroundColor: terminalBackgroundColor, opacity: showConnectingOverlay || isRetrying ? 0 : 1, }} javaScriptEnabled={true} @@ -742,7 +938,7 @@ const TerminalComponent = forwardRef( bottom: 0, justifyContent: "center", alignItems: "center", - backgroundColor: BACKGROUNDS.DARKEST, + backgroundColor: terminalBackgroundColor, padding: 20, }} > @@ -807,6 +1003,37 @@ const TerminalComponent = forwardRef( )} + + {/* TOTP Dialog */} + { + setTotpRequired(false); + setTotpPrompt(""); + setIsPasswordPrompt(false); + if (onClose) onClose(); + }} + prompt={totpPrompt} + isPasswordPrompt={isPasswordPrompt} + /> + + {/* SSH Auth Dialog */} + { + setShowAuthDialog(false); + if (onClose) onClose(); + }} + hostInfo={{ + name: hostConfig.name, + ip: hostConfig.ip, + port: hostConfig.port, + username: hostConfig.username, + }} + reason={authDialogReason} + /> ); }, diff --git a/app/Tabs/Sessions/BottomToolbar.tsx b/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx similarity index 98% rename from app/Tabs/Sessions/BottomToolbar.tsx rename to app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx index d58224a..b04c80f 100644 --- a/app/Tabs/Sessions/BottomToolbar.tsx +++ b/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { View, Text, TouchableOpacity } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { TerminalHandle } from "./Terminal"; +import { TerminalHandle } from "../Terminal"; import CustomKeyboard from "./CustomKeyboard"; import SnippetsBar from "./SnippetsBar"; import { diff --git a/app/Tabs/Sessions/CustomKeyboard.tsx b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx similarity index 99% rename from app/Tabs/Sessions/CustomKeyboard.tsx rename to app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx index ad4ad27..517c0d1 100644 --- a/app/Tabs/Sessions/CustomKeyboard.tsx +++ b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx @@ -1,6 +1,6 @@ import React from "react"; import { View, ScrollView, Clipboard, Text } from "react-native"; -import { TerminalHandle } from "./Terminal"; +import { TerminalHandle } from "../Terminal"; import KeyboardKey from "./KeyboardKey"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; import { KeyConfig } from "@/types/keyboard"; diff --git a/app/Tabs/Sessions/KeyDefinitions.ts b/app/tabs/sessions/terminal/keyboard/KeyDefinitions.ts similarity index 100% rename from app/Tabs/Sessions/KeyDefinitions.ts rename to app/tabs/sessions/terminal/keyboard/KeyDefinitions.ts diff --git a/app/Tabs/Sessions/KeyboardBar.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx similarity index 98% rename from app/Tabs/Sessions/KeyboardBar.tsx rename to app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx index 4ff4386..0f01d73 100644 --- a/app/Tabs/Sessions/KeyboardBar.tsx +++ b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { View, ScrollView, Text, Clipboard, Platform } from "react-native"; -import { TerminalHandle } from "./Terminal"; +import { TerminalHandle } from "../Terminal"; import KeyboardKey from "./KeyboardKey"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; import { KeyConfig } from "@/types/keyboard"; diff --git a/app/Tabs/Sessions/KeyboardKey.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx similarity index 100% rename from app/Tabs/Sessions/KeyboardKey.tsx rename to app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx diff --git a/app/Tabs/Sessions/SnippetsBar.tsx b/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx similarity index 99% rename from app/Tabs/Sessions/SnippetsBar.tsx rename to app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx index 1da03cd..c0c6fde 100644 --- a/app/Tabs/Sessions/SnippetsBar.tsx +++ b/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx @@ -6,7 +6,7 @@ import { TouchableOpacity, ActivityIndicator, } from "react-native"; -import { TerminalHandle } from "./Terminal"; +import { TerminalHandle } from "@/app/tabs/sessions/terminal/Terminal"; import { getSnippets, getSnippetFolders } from "@/app/main-axios"; import { showToast } from "@/app/utils/toast"; import { BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; diff --git a/app/tabs/sessions/tunnel/TunnelCard.tsx b/app/tabs/sessions/tunnel/TunnelCard.tsx new file mode 100644 index 0000000..391107b --- /dev/null +++ b/app/tabs/sessions/tunnel/TunnelCard.tsx @@ -0,0 +1,338 @@ +import React from "react"; +import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native"; +import { + CheckCircle, + Loader2, + AlertCircle, + Clock, + Circle, + Play, + Square, + X, + RotateCcw, +} from "lucide-react-native"; +import type { TunnelCardProps } from "@/types"; +import { + BACKGROUNDS, + BORDER_COLORS, + RADIUS, + TEXT_COLORS, +} from "@/app/constants/designTokens"; + +const TunnelCard: React.FC = ({ + tunnel, + tunnelName, + status, + isLoading, + onAction, +}) => { + const getStatusInfo = () => { + if (!status) { + return { + label: "Disconnected", + color: "#6b7280", + icon: Circle, + bgColor: "rgba(107, 114, 128, 0.1)", + }; + } + + const statusUpper = status.status?.toUpperCase() || "DISCONNECTED"; + + switch (statusUpper) { + case "CONNECTED": + return { + label: "Connected", + color: "#10b981", + icon: CheckCircle, + bgColor: "rgba(16, 185, 129, 0.1)", + }; + case "CONNECTING": + return { + label: "Connecting", + color: "#3b82f6", + icon: Loader2, + bgColor: "rgba(59, 130, 246, 0.1)", + }; + case "DISCONNECTING": + return { + label: "Disconnecting", + color: "#f59e0b", + icon: Loader2, + bgColor: "rgba(245, 158, 11, 0.1)", + }; + case "ERROR": + case "FAILED": + return { + label: "Error", + color: "#ef4444", + icon: AlertCircle, + bgColor: "rgba(239, 68, 68, 0.1)", + }; + case "RETRYING": + return { + label: `Retrying (${status.retryCount || 0}/${status.maxRetries || 0})`, + color: "#f59e0b", + icon: RotateCcw, + bgColor: "rgba(245, 158, 11, 0.1)", + }; + case "WAITING": + return { + label: status.nextRetryIn + ? `Waiting (${Math.ceil(status.nextRetryIn / 1000)}s)` + : "Waiting", + color: "#8b5cf6", + icon: Clock, + bgColor: "rgba(139, 92, 246, 0.1)", + }; + default: + return { + label: statusUpper, + color: "#6b7280", + icon: Circle, + bgColor: "rgba(107, 114, 128, 0.1)", + }; + } + }; + + const statusInfo = getStatusInfo(); + const StatusIcon = statusInfo.icon; + + // Determine available actions + const statusValue = status?.status?.toUpperCase() || "DISCONNECTED"; + const canConnect = + !status || statusValue === "DISCONNECTED" || statusValue === "ERROR" || statusValue === "FAILED"; + const canDisconnect = statusValue === "CONNECTED"; + const canCancel = + statusValue === "CONNECTING" || + statusValue === "RETRYING" || + statusValue === "WAITING"; + + // Format port mapping display + const portMapping = `${tunnel.sourcePort} → ${tunnel.endpointHost}:${tunnel.endpointPort}`; + + return ( + + {/* Header: Status Badge */} + + + + Tunnel + + + + + + {statusInfo.label} + + + + + {/* Port Mapping */} + + + Port Mapping + + + {portMapping} + + + + {/* Error Message */} + {(statusValue === "ERROR" || statusValue === "FAILED") && + status?.reason && ( + + + {status.reason} + + + )} + + {/* Action Buttons */} + + {canConnect && ( + onAction("connect")} + disabled={isLoading} + activeOpacity={0.7} + > + {isLoading ? ( + + ) : ( + <> + + + Connect + + + )} + + )} + + {canDisconnect && ( + onAction("disconnect")} + disabled={isLoading} + activeOpacity={0.7} + > + {isLoading ? ( + + ) : ( + <> + + + Disconnect + + + )} + + )} + + {canCancel && ( + onAction("cancel")} + disabled={isLoading} + activeOpacity={0.7} + > + {isLoading ? ( + + ) : ( + <> + + + Cancel + + + )} + + )} + + + ); +}; + +export default TunnelCard; diff --git a/app/tabs/sessions/tunnel/TunnelManager.tsx b/app/tabs/sessions/tunnel/TunnelManager.tsx new file mode 100644 index 0000000..30a8f07 --- /dev/null +++ b/app/tabs/sessions/tunnel/TunnelManager.tsx @@ -0,0 +1,452 @@ +import React, { + useRef, + useEffect, + useState, + useCallback, + forwardRef, + useImperativeHandle, +} from "react"; +import { + View, + Text, + ScrollView, + ActivityIndicator, + RefreshControl, + TouchableOpacity, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Activity } from "lucide-react-native"; +import { + getTunnelStatuses, + connectTunnel, + disconnectTunnel, + cancelTunnel, + getSSHHosts, +} from "../../../main-axios"; +import { showToast } from "../../../utils/toast"; +import type { TunnelStatus, SSHHost, TunnelConnection } from "../../../../types"; +import { useOrientation } from "@/app/utils/orientation"; +import { + getResponsivePadding, + getColumnCount, +} from "@/app/utils/responsive"; +import { + BACKGROUNDS, + BORDER_COLORS, + RADIUS, + TEXT_COLORS, +} from "@/app/constants/designTokens"; +import TunnelCard from "@/app/tabs/sessions/tunnel/TunnelCard"; +import type { TunnelSessionProps } from "@/types"; + +export type TunnelManagerHandle = { + refresh: () => void; +}; + +export const TunnelManager = forwardRef( + ({ hostConfig, isVisible, title = "Manage Tunnels", onClose }, ref) => { + const insets = useSafeAreaInsets(); + const { width, isLandscape } = useOrientation(); + const [tunnelStatuses, setTunnelStatuses] = useState< + Record + >({}); + const [loadingTunnels, setLoadingTunnels] = useState>( + new Set() + ); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [allHosts, setAllHosts] = useState([]); + const refreshIntervalRef = useRef(null); + + const padding = getResponsivePadding(isLandscape); + const columnCount = getColumnCount(width, isLandscape, 350); + + const fetchTunnelStatuses = useCallback( + async (showLoadingSpinner = true) => { + try { + if (showLoadingSpinner) { + setIsLoading(true); + } + setError(null); + + const statuses = await getTunnelStatuses(); + setTunnelStatuses(statuses); + } catch (err: any) { + const errorMessage = err?.message || "Failed to fetch tunnel statuses"; + setError(errorMessage); + if (showLoadingSpinner) { + showToast.error(errorMessage); + } + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, + [] + ); + + const fetchAllHosts = useCallback(async () => { + try { + const hosts = await getSSHHosts(); + setAllHosts(hosts); + } catch (err: any) { + console.error("Failed to fetch hosts for tunnel endpoint lookup:", err); + } + }, []); + + const handleRefresh = useCallback(() => { + setIsRefreshing(true); + fetchTunnelStatuses(false); + }, [fetchTunnelStatuses]); + + useImperativeHandle( + ref, + () => ({ + refresh: handleRefresh, + }), + [handleRefresh] + ); + + useEffect(() => { + if (isVisible) { + fetchTunnelStatuses(); + fetchAllHosts(); + + refreshIntervalRef.current = setInterval(() => { + fetchTunnelStatuses(false); + }, 5000); + } else { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + } + + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + } + }; + }, [isVisible, fetchTunnelStatuses, fetchAllHosts]); + + const handleTunnelAction = async ( + action: "connect" | "disconnect" | "cancel", + tunnelIndex: number + ) => { + const tunnel = hostConfig.tunnelConnections[tunnelIndex]; + const tunnelName = `${hostConfig.name || `${hostConfig.id}`}_${tunnel.sourcePort}_${tunnel.endpointHost}_${tunnel.endpointPort}`; + + setLoadingTunnels((prev) => new Set(prev).add(tunnelName)); + + try { + if (action === "connect") { + // Find endpoint host + const endpointHost = allHosts.find( + (h) => + h.name === tunnel.endpointHost || + `${h.username}@${h.ip}` === tunnel.endpointHost + ); + + if (!endpointHost) { + throw new Error(`Endpoint host not found: ${tunnel.endpointHost}`); + } + + // Build the full host object from hostConfig + const sourceHost: Partial = { + id: hostConfig.id, + name: hostConfig.name, + ip: "", // We don't have these in hostConfig + port: 0, + username: "", + authType: "none", + folder: "", + tags: [], + pin: false, + enableTerminal: true, + enableTunnel: true, + enableFileManager: true, + defaultPath: "", + tunnelConnections: hostConfig.tunnelConnections, + createdAt: "", + updatedAt: "", + }; + + // We need to fetch the full host data to get auth details + const fullHost = allHosts.find((h) => h.id === hostConfig.id); + if (!fullHost) { + throw new Error("Source host not found"); + } + + const tunnelConfig = { + name: tunnelName, + hostName: fullHost.name || `${fullHost.username}@${fullHost.ip}`, + sourceIP: fullHost.ip, + sourceSSHPort: fullHost.port, + sourceUsername: fullHost.username, + sourcePassword: + fullHost.authType === "password" ? fullHost.password : undefined, + sourceAuthMethod: fullHost.authType, + sourceSSHKey: + fullHost.authType === "key" ? fullHost.key : undefined, + sourceKeyPassword: + fullHost.authType === "key" ? fullHost.keyPassword : undefined, + sourceKeyType: + fullHost.authType === "key" ? fullHost.keyType : undefined, + sourceCredentialId: fullHost.credentialId, + sourceUserId: fullHost.userId, + endpointIP: endpointHost.ip, + endpointSSHPort: endpointHost.port, + endpointUsername: endpointHost.username, + endpointPassword: + endpointHost.authType === "password" + ? endpointHost.password + : undefined, + endpointAuthMethod: endpointHost.authType, + endpointSSHKey: + endpointHost.authType === "key" ? endpointHost.key : undefined, + endpointKeyPassword: + endpointHost.authType === "key" + ? endpointHost.keyPassword + : undefined, + endpointKeyType: + endpointHost.authType === "key" + ? endpointHost.keyType + : undefined, + endpointCredentialId: endpointHost.credentialId, + endpointUserId: endpointHost.userId, + sourcePort: tunnel.sourcePort, + endpointPort: tunnel.endpointPort, + maxRetries: tunnel.maxRetries, + retryInterval: tunnel.retryInterval * 1000, + autoStart: tunnel.autoStart, + isPinned: fullHost.pin, + }; + + await connectTunnel(tunnelConfig); + showToast.success(`Connecting tunnel on port ${tunnel.sourcePort}`); + } else if (action === "disconnect") { + await disconnectTunnel(tunnelName); + showToast.success(`Disconnecting tunnel on port ${tunnel.sourcePort}`); + } else if (action === "cancel") { + await cancelTunnel(tunnelName); + showToast.success(`Cancelling tunnel on port ${tunnel.sourcePort}`); + } + + // Immediate refresh after action + await fetchTunnelStatuses(false); + } catch (err: any) { + const errorMsg = err?.message || `Failed to ${action} tunnel`; + showToast.error(errorMsg); + } finally { + setLoadingTunnels((prev) => { + const newSet = new Set(prev); + newSet.delete(tunnelName); + return newSet; + }); + } + }; + + const cardWidth = + isLandscape && columnCount > 1 ? `${100 / columnCount - 1}%` : "100%"; + + if (!isVisible) { + return null; + } + + return ( + + {isLoading && !tunnelStatuses ? ( + + + + Loading tunnels... + + + ) : error ? ( + + + + Failed to Load Tunnels + + + {error} + + + + Retry + + + + ) : !hostConfig.enableTunnel || + !hostConfig.tunnelConnections || + hostConfig.tunnelConnections.length === 0 ? ( + + + + No Tunnels Configured + + + This host doesn't have any SSH tunnels configured. + + + Configure tunnels from the desktop app. + + + ) : ( + + } + > + + + SSH Tunnels + + + {hostConfig.tunnelConnections.length} tunnel + {hostConfig.tunnelConnections.length !== 1 ? "s" : ""} configured + for {hostConfig.name} + + + + 1 ? "row" : "column", + flexWrap: "wrap", + gap: 12, + }} + > + {hostConfig.tunnelConnections.map((tunnel, idx) => { + const tunnelName = `${hostConfig.name || `${hostConfig.id}`}_${tunnel.sourcePort}_${tunnel.endpointHost}_${tunnel.endpointPort}`; + const status = tunnelStatuses[tunnelName] || null; + const isLoadingTunnel = loadingTunnels.has(tunnelName); + + return ( + 1 ? 0 : 12, + }} + > + + handleTunnelAction(action, idx) + } + /> + + ); + })} + + + )} + + ); + } +); + +export default TunnelManager; diff --git a/app/Tabs/Settings/KeyboardCustomization.tsx b/app/tabs/settings/KeyboardCustomization.tsx similarity index 99% rename from app/Tabs/Settings/KeyboardCustomization.tsx rename to app/tabs/settings/KeyboardCustomization.tsx index 715630d..74381a3 100644 --- a/app/Tabs/Settings/KeyboardCustomization.tsx +++ b/app/tabs/settings/KeyboardCustomization.tsx @@ -11,7 +11,7 @@ import { import { useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; -import { PRESET_DEFINITIONS } from "@/app/Tabs/Sessions/KeyDefinitions"; +import { PRESET_DEFINITIONS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; import { PresetType, KeyConfig } from "@/types/keyboard"; import { showToast } from "@/app/utils/toast"; import KeySelector from "./components/KeySelector"; diff --git a/app/Tabs/Settings/Settings.tsx b/app/tabs/settings/Settings.tsx similarity index 100% rename from app/Tabs/Settings/Settings.tsx rename to app/tabs/settings/Settings.tsx diff --git a/app/Tabs/Settings/TerminalCustomization.tsx b/app/tabs/settings/TerminalCustomization.tsx similarity index 100% rename from app/Tabs/Settings/TerminalCustomization.tsx rename to app/tabs/settings/TerminalCustomization.tsx diff --git a/app/Tabs/Settings/components/DraggableKeyList.tsx b/app/tabs/settings/components/DraggableKeyList.tsx similarity index 100% rename from app/Tabs/Settings/components/DraggableKeyList.tsx rename to app/tabs/settings/components/DraggableKeyList.tsx diff --git a/app/Tabs/Settings/components/DraggableRowList.tsx b/app/tabs/settings/components/DraggableRowList.tsx similarity index 100% rename from app/Tabs/Settings/components/DraggableRowList.tsx rename to app/tabs/settings/components/DraggableRowList.tsx diff --git a/app/Tabs/Settings/components/KeySelector.tsx b/app/tabs/settings/components/KeySelector.tsx similarity index 98% rename from app/Tabs/Settings/components/KeySelector.tsx rename to app/tabs/settings/components/KeySelector.tsx index d489127..a766bdc 100644 --- a/app/Tabs/Settings/components/KeySelector.tsx +++ b/app/tabs/settings/components/KeySelector.tsx @@ -10,7 +10,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { KeyConfig, KeyCategory } from "@/types/keyboard"; -import { ALL_KEYS } from "@/app/Tabs/Sessions/KeyDefinitions"; +import { ALL_KEYS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; interface KeySelectorProps { visible: boolean; diff --git a/app/Tabs/Settings/components/UnifiedDraggableList.tsx b/app/tabs/settings/components/UnifiedDraggableList.tsx similarity index 100% rename from app/Tabs/Settings/components/UnifiedDraggableList.tsx rename to app/tabs/settings/components/UnifiedDraggableList.tsx diff --git a/constants/stats-config.ts b/constants/stats-config.ts new file mode 100644 index 0000000..98d4cfd --- /dev/null +++ b/constants/stats-config.ts @@ -0,0 +1,21 @@ +export type WidgetType = + | 'cpu' + | 'memory' + | 'disk' + | 'network' + | 'uptime' + | 'processes' + | 'system' + | 'login_stats'; + +export interface StatsConfig { + enabledWidgets: WidgetType[]; + statusCheckEnabled?: boolean; + metricsEnabled?: boolean; +} + +export const DEFAULT_STATS_CONFIG: StatsConfig = { + enabledWidgets: ['cpu', 'memory', 'disk', 'uptime'], + statusCheckEnabled: true, + metricsEnabled: true, +}; diff --git a/constants/terminal-config.ts b/constants/terminal-config.ts new file mode 100644 index 0000000..ce30506 --- /dev/null +++ b/constants/terminal-config.ts @@ -0,0 +1,25 @@ +import { TerminalConfig } from '@/types'; +import { DEFAULT_TERMINAL_CONFIG } from './terminal-themes'; + +// Mobile-specific defaults (simpler than desktop) +export const MOBILE_DEFAULT_TERMINAL_CONFIG: Partial = { + ...DEFAULT_TERMINAL_CONFIG, + // Override desktop defaults for mobile + fontSize: 14, // Smaller for mobile screens + rightClickSelectsWord: false, // Not applicable on mobile + minimumContrastRatio: 1, // Keep simple +}; + +// Supported features for mobile (subset of desktop) +export const MOBILE_SUPPORTED_FEATURES = { + themes: true, + fonts: true, + cursorCustomization: true, + environmentVariables: true, + startupSnippet: true, + initialPath: false, // Not for terminal (file manager only) + agentForwarding: true, + autoMosh: false, // Skip for mobile simplicity + commandHistory: false, // Skip complex WebView features + commandAutocomplete: false, +}; diff --git a/constants/terminal-themes.ts b/constants/terminal-themes.ts new file mode 100644 index 0000000..14620fc --- /dev/null +++ b/constants/terminal-themes.ts @@ -0,0 +1,710 @@ +export interface TerminalTheme { + name: string; + category: "dark" | "light" | "colorful"; + colors: { + background: string; + foreground: string; + cursor?: string; + cursorAccent?: string; + selectionBackground?: string; + selectionForeground?: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; + }; +} + +export const TERMINAL_THEMES: Record = { + termix: { + name: "Termix Default", + category: "dark", + colors: { + background: "#18181b", + foreground: "#f7f7f7", + cursor: "#f7f7f7", + cursorAccent: "#18181b", + selectionBackground: "#3a3a3d", + black: "#2e3436", + red: "#cc0000", + green: "#4e9a06", + yellow: "#c4a000", + blue: "#3465a4", + magenta: "#75507b", + cyan: "#06989a", + white: "#d3d7cf", + brightBlack: "#555753", + brightRed: "#ef2929", + brightGreen: "#8ae234", + brightYellow: "#fce94f", + brightBlue: "#729fcf", + brightMagenta: "#ad7fa8", + brightCyan: "#34e2e2", + brightWhite: "#eeeeec", + }, + }, + + dracula: { + name: "Dracula", + category: "dark", + colors: { + background: "#282a36", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#282a36", + selectionBackground: "#44475a", + black: "#21222c", + red: "#ff5555", + green: "#50fa7b", + yellow: "#f1fa8c", + blue: "#bd93f9", + magenta: "#ff79c6", + cyan: "#8be9fd", + white: "#f8f8f2", + brightBlack: "#6272a4", + brightRed: "#ff6e6e", + brightGreen: "#69ff94", + brightYellow: "#ffffa5", + brightBlue: "#d6acff", + brightMagenta: "#ff92df", + brightCyan: "#a4ffff", + brightWhite: "#ffffff", + }, + }, + + monokai: { + name: "Monokai", + category: "dark", + colors: { + background: "#272822", + foreground: "#f8f8f2", + cursor: "#f8f8f0", + cursorAccent: "#272822", + selectionBackground: "#49483e", + black: "#272822", + red: "#f92672", + green: "#a6e22e", + yellow: "#f4bf75", + blue: "#66d9ef", + magenta: "#ae81ff", + cyan: "#a1efe4", + white: "#f8f8f2", + brightBlack: "#75715e", + brightRed: "#f92672", + brightGreen: "#a6e22e", + brightYellow: "#f4bf75", + brightBlue: "#66d9ef", + brightMagenta: "#ae81ff", + brightCyan: "#a1efe4", + brightWhite: "#f9f8f5", + }, + }, + + nord: { + name: "Nord", + category: "dark", + colors: { + background: "#2e3440", + foreground: "#d8dee9", + cursor: "#d8dee9", + cursorAccent: "#2e3440", + selectionBackground: "#434c5e", + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#81a1c1", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#e5e9f0", + brightBlack: "#4c566a", + brightRed: "#bf616a", + brightGreen: "#a3be8c", + brightYellow: "#ebcb8b", + brightBlue: "#81a1c1", + brightMagenta: "#b48ead", + brightCyan: "#8fbcbb", + brightWhite: "#eceff4", + }, + }, + + gruvboxDark: { + name: "Gruvbox Dark", + category: "dark", + colors: { + background: "#282828", + foreground: "#ebdbb2", + cursor: "#ebdbb2", + cursorAccent: "#282828", + selectionBackground: "#504945", + black: "#282828", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + magenta: "#b16286", + cyan: "#689d6a", + white: "#a89984", + brightBlack: "#928374", + brightRed: "#fb4934", + brightGreen: "#b8bb26", + brightYellow: "#fabd2f", + brightBlue: "#83a598", + brightMagenta: "#d3869b", + brightCyan: "#8ec07c", + brightWhite: "#ebdbb2", + }, + }, + + gruvboxLight: { + name: "Gruvbox Light", + category: "light", + colors: { + background: "#fbf1c7", + foreground: "#3c3836", + cursor: "#3c3836", + cursorAccent: "#fbf1c7", + selectionBackground: "#d5c4a1", + black: "#fbf1c7", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + magenta: "#b16286", + cyan: "#689d6a", + white: "#7c6f64", + brightBlack: "#928374", + brightRed: "#9d0006", + brightGreen: "#79740e", + brightYellow: "#b57614", + brightBlue: "#076678", + brightMagenta: "#8f3f71", + brightCyan: "#427b58", + brightWhite: "#3c3836", + }, + }, + + solarizedDark: { + name: "Solarized Dark", + category: "dark", + colors: { + background: "#002b36", + foreground: "#839496", + cursor: "#839496", + cursorAccent: "#002b36", + selectionBackground: "#073642", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#002b36", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + }, + + solarizedLight: { + name: "Solarized Light", + category: "light", + colors: { + background: "#fdf6e3", + foreground: "#657b83", + cursor: "#657b83", + cursorAccent: "#fdf6e3", + selectionBackground: "#eee8d5", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#002b36", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + }, + + oneDark: { + name: "One Dark", + category: "dark", + colors: { + background: "#282c34", + foreground: "#abb2bf", + cursor: "#528bff", + cursorAccent: "#282c34", + selectionBackground: "#3e4451", + black: "#282c34", + red: "#e06c75", + green: "#98c379", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#abb2bf", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", + }, + }, + + tokyoNight: { + name: "Tokyo Night", + category: "dark", + colors: { + background: "#1a1b26", + foreground: "#a9b1d6", + cursor: "#a9b1d6", + cursorAccent: "#1a1b26", + selectionBackground: "#283457", + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", + }, + }, + + ayuDark: { + name: "Ayu Dark", + category: "dark", + colors: { + background: "#0a0e14", + foreground: "#b3b1ad", + cursor: "#e6b450", + cursorAccent: "#0a0e14", + selectionBackground: "#253340", + black: "#01060e", + red: "#ea6c73", + green: "#91b362", + yellow: "#f9af4f", + blue: "#53bdfa", + magenta: "#fae994", + cyan: "#90e1c6", + white: "#c7c7c7", + brightBlack: "#686868", + brightRed: "#f07178", + brightGreen: "#c2d94c", + brightYellow: "#ffb454", + brightBlue: "#59c2ff", + brightMagenta: "#ffee99", + brightCyan: "#95e6cb", + brightWhite: "#ffffff", + }, + }, + + ayuLight: { + name: "Ayu Light", + category: "light", + colors: { + background: "#fafafa", + foreground: "#5c6166", + cursor: "#ff9940", + cursorAccent: "#fafafa", + selectionBackground: "#d1e4f4", + black: "#000000", + red: "#f51818", + green: "#86b300", + yellow: "#f2ae49", + blue: "#399ee6", + magenta: "#a37acc", + cyan: "#4cbf99", + white: "#c7c7c7", + brightBlack: "#686868", + brightRed: "#ff3333", + brightGreen: "#b8e532", + brightYellow: "#ffc849", + brightBlue: "#59c2ff", + brightMagenta: "#bf7ce0", + brightCyan: "#5cf7a0", + brightWhite: "#ffffff", + }, + }, + + materialTheme: { + name: "Material Theme", + category: "dark", + colors: { + background: "#263238", + foreground: "#eeffff", + cursor: "#ffcc00", + cursorAccent: "#263238", + selectionBackground: "#546e7a", + black: "#000000", + red: "#e53935", + green: "#91b859", + yellow: "#ffb62c", + blue: "#6182b8", + magenta: "#7c4dff", + cyan: "#39adb5", + white: "#ffffff", + brightBlack: "#546e7a", + brightRed: "#ff5370", + brightGreen: "#c3e88d", + brightYellow: "#ffcb6b", + brightBlue: "#82aaff", + brightMagenta: "#c792ea", + brightCyan: "#89ddff", + brightWhite: "#ffffff", + }, + }, + + palenight: { + name: "Palenight", + category: "dark", + colors: { + background: "#292d3e", + foreground: "#a6accd", + cursor: "#ffcc00", + cursorAccent: "#292d3e", + selectionBackground: "#676e95", + black: "#292d3e", + red: "#f07178", + green: "#c3e88d", + yellow: "#ffcb6b", + blue: "#82aaff", + magenta: "#c792ea", + cyan: "#89ddff", + white: "#d0d0d0", + brightBlack: "#434758", + brightRed: "#ff8b92", + brightGreen: "#ddffa7", + brightYellow: "#ffe585", + brightBlue: "#9cc4ff", + brightMagenta: "#e1acff", + brightCyan: "#a3f7ff", + brightWhite: "#ffffff", + }, + }, + + oceanicNext: { + name: "Oceanic Next", + category: "dark", + colors: { + background: "#1b2b34", + foreground: "#cdd3de", + cursor: "#c0c5ce", + cursorAccent: "#1b2b34", + selectionBackground: "#343d46", + black: "#343d46", + red: "#ec5f67", + green: "#99c794", + yellow: "#fac863", + blue: "#6699cc", + magenta: "#c594c5", + cyan: "#5fb3b3", + white: "#cdd3de", + brightBlack: "#65737e", + brightRed: "#ec5f67", + brightGreen: "#99c794", + brightYellow: "#fac863", + brightBlue: "#6699cc", + brightMagenta: "#c594c5", + brightCyan: "#5fb3b3", + brightWhite: "#d8dee9", + }, + }, + + nightOwl: { + name: "Night Owl", + category: "dark", + colors: { + background: "#011627", + foreground: "#d6deeb", + cursor: "#80a4c2", + cursorAccent: "#011627", + selectionBackground: "#1d3b53", + black: "#011627", + red: "#ef5350", + green: "#22da6e", + yellow: "#c5e478", + blue: "#82aaff", + magenta: "#c792ea", + cyan: "#21c7a8", + white: "#ffffff", + brightBlack: "#575656", + brightRed: "#ef5350", + brightGreen: "#22da6e", + brightYellow: "#ffeb95", + brightBlue: "#82aaff", + brightMagenta: "#c792ea", + brightCyan: "#7fdbca", + brightWhite: "#ffffff", + }, + }, + + synthwave84: { + name: "Synthwave '84", + category: "colorful", + colors: { + background: "#241b2f", + foreground: "#f92aad", + cursor: "#f92aad", + cursorAccent: "#241b2f", + selectionBackground: "#495495", + black: "#000000", + red: "#f6188f", + green: "#1eff8e", + yellow: "#ffe261", + blue: "#03edf9", + magenta: "#f10596", + cyan: "#03edf9", + white: "#ffffff", + brightBlack: "#5a5a5a", + brightRed: "#ff1a8e", + brightGreen: "#1eff8e", + brightYellow: "#ffff00", + brightBlue: "#00d8ff", + brightMagenta: "#ff00d4", + brightCyan: "#00ffff", + brightWhite: "#ffffff", + }, + }, + + cobalt2: { + name: "Cobalt2", + category: "dark", + colors: { + background: "#193549", + foreground: "#ffffff", + cursor: "#f0cc09", + cursorAccent: "#193549", + selectionBackground: "#0050a4", + black: "#000000", + red: "#ff0000", + green: "#38de21", + yellow: "#ffe50a", + blue: "#1460d2", + magenta: "#ff005d", + cyan: "#00bbbb", + white: "#bbbbbb", + brightBlack: "#555555", + brightRed: "#f40e17", + brightGreen: "#3bd01d", + brightYellow: "#edc809", + brightBlue: "#5555ff", + brightMagenta: "#ff55ff", + brightCyan: "#6ae3fa", + brightWhite: "#ffffff", + }, + }, + + snazzy: { + name: "Snazzy", + category: "dark", + colors: { + background: "#282a36", + foreground: "#eff0eb", + cursor: "#97979b", + cursorAccent: "#282a36", + selectionBackground: "#97979b", + black: "#282a36", + red: "#ff5c57", + green: "#5af78e", + yellow: "#f3f99d", + blue: "#57c7ff", + magenta: "#ff6ac1", + cyan: "#9aedfe", + white: "#f1f1f0", + brightBlack: "#686868", + brightRed: "#ff5c57", + brightGreen: "#5af78e", + brightYellow: "#f3f99d", + brightBlue: "#57c7ff", + brightMagenta: "#ff6ac1", + brightCyan: "#9aedfe", + brightWhite: "#eff0eb", + }, + }, + + atomOneDark: { + name: "Atom One Dark", + category: "dark", + colors: { + background: "#1e2127", + foreground: "#abb2bf", + cursor: "#528bff", + cursorAccent: "#1e2127", + selectionBackground: "#3e4451", + black: "#000000", + red: "#e06c75", + green: "#98c379", + yellow: "#d19a66", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#abb2bf", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#d19a66", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", + }, + }, + + catppuccinMocha: { + name: "Catppuccin Mocha", + category: "dark", + colors: { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + cursorAccent: "#1e1e2e", + selectionBackground: "#585b70", + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f38ba8", + brightGreen: "#a6e3a1", + brightYellow: "#f9e2af", + brightBlue: "#89b4fa", + brightMagenta: "#f5c2e7", + brightCyan: "#94e2d5", + brightWhite: "#a6adc8", + }, + }, +}; + +export const TERMINAL_FONTS = [ + { + value: "Caskaydia Cove Nerd Font Mono", + label: "Caskaydia Cove Nerd Font Mono", + fallback: + '"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "JetBrains Mono", + label: "JetBrains Mono", + fallback: + '"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "Fira Code", + label: "Fira Code", + fallback: '"Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "Cascadia Code", + label: "Cascadia Code", + fallback: + '"Cascadia Code", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "Source Code Pro", + label: "Source Code Pro", + fallback: + '"Source Code Pro", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "SF Mono", + label: "SF Mono", + fallback: '"SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "Consolas", + label: "Consolas", + fallback: 'Consolas, "Liberation Mono", monospace', + }, + { + value: "Monaco", + label: "Monaco", + fallback: 'Monaco, "Liberation Mono", monospace', + }, +]; + +export const CURSOR_STYLES = [ + { value: "block", label: "Block" }, + { value: "underline", label: "Underline" }, + { value: "bar", label: "Bar" }, +] as const; + +export const BELL_STYLES = [ + { value: "none", label: "None" }, + { value: "sound", label: "Sound" }, + { value: "visual", label: "Visual" }, + { value: "both", label: "Both" }, +] as const; + +export const FAST_SCROLL_MODIFIERS = [ + { value: "alt", label: "Alt" }, + { value: "ctrl", label: "Ctrl" }, + { value: "shift", label: "Shift" }, +] as const; + +export const DEFAULT_TERMINAL_CONFIG = { + cursorBlink: true, + cursorStyle: "bar" as const, + fontSize: 14, + fontFamily: "Caskaydia Cove Nerd Font Mono", + letterSpacing: 0, + lineHeight: 1.2, + theme: "termix", + + scrollback: 10000, + bellStyle: "none" as const, + rightClickSelectsWord: false, + fastScrollModifier: "alt" as const, + fastScrollSensitivity: 5, + minimumContrastRatio: 1, + + backspaceMode: "normal" as const, + agentForwarding: false, + environmentVariables: [] as Array<{ key: string; value: string }>, + startupSnippetId: null as number | null, + autoMosh: false, + moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8", +}; + +export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG; diff --git a/types/index.ts b/types/index.ts index c405547..7c1c417 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,11 +1,3 @@ -// ============================================================================ -// CENTRAL TYPE DEFINITIONS -// ============================================================================ -// This file contains all shared interfaces and types used across the application -// to avoid duplication and ensure consistency. - -// Note: SSH2 and Express types not imported for React Native compatibility - // ============================================================================ // SSH HOST TYPES // ============================================================================ @@ -357,6 +349,26 @@ export interface TabContextTab { initialTab?: string; } +export interface TunnelSessionProps { + hostConfig: { + id: number; + name: string; + enableTunnel: boolean; + tunnelConnections: TunnelConnection[]; + }; + isVisible: boolean; + title?: string; + onClose?: () => void; +} + +export interface TunnelCardProps { + tunnel: TunnelConnection; + tunnelName: string; + status: TunnelStatus | null; + isLoading: boolean; + onAction: (action: "connect" | "disconnect" | "cancel") => Promise; +} + export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; export interface SplitConfiguration { From dc874c140251917e800dc4b712b76f7fca8205a4 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 11 Dec 2025 21:25:33 -0600 Subject: [PATCH 12/27] feat: Add terminal customization, totp/auth dialog support, and overall UI improvements (local squash commit) --- app/contexts/TerminalCustomizationContext.tsx | 3 +- app/contexts/TerminalSessionsContext.tsx | 15 +- app/main-axios.ts | 4 +- app/tabs/dialogs/SSHAuthDialog.tsx | 340 +++++--- app/tabs/dialogs/TOTPDialog.tsx | 282 +++++-- app/tabs/dialogs/index.ts | 4 +- app/tabs/hosts/navigation/Host.tsx | 180 +++-- app/tabs/sessions/Sessions.tsx | 27 +- .../sessions/file-manager/FileManager.tsx | 4 +- app/tabs/sessions/file-manager/FileViewer.tsx | 1 + app/tabs/sessions/navigation/TabBar.tsx | 350 +++++---- .../sessions/server-stats/ServerStats.tsx | 329 ++++---- .../server-stats/widgets/CpuWidget.tsx | 55 +- .../server-stats/widgets/DiskWidget.tsx | 43 +- .../server-stats/widgets/LoginStatsWidget.tsx | 86 -- .../server-stats/widgets/MemoryWidget.tsx | 43 +- .../server-stats/widgets/NetworkWidget.tsx | 86 -- .../server-stats/widgets/ProcessesWidget.tsx | 86 -- .../server-stats/widgets/SystemWidget.tsx | 86 -- .../server-stats/widgets/UptimeWidget.tsx | 86 -- .../sessions/server-stats/widgets/index.ts | 11 +- app/tabs/sessions/terminal/Terminal.tsx | 222 +++--- .../terminal/keyboard/CustomKeyboard.tsx | 2 +- .../terminal/keyboard/KeyboardBar.tsx | 64 +- app/tabs/sessions/tunnel/TunnelCard.tsx | 13 +- app/tabs/sessions/tunnel/TunnelManager.tsx | 737 +++++++++--------- app/tabs/settings/Settings.tsx | 4 +- app/tabs/settings/TerminalCustomization.tsx | 2 + constants/stats-config.ts | 7 +- constants/terminal-config.ts | 24 +- 30 files changed, 1572 insertions(+), 1624 deletions(-) delete mode 100644 app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx delete mode 100644 app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx delete mode 100644 app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx delete mode 100644 app/tabs/sessions/server-stats/widgets/SystemWidget.tsx delete mode 100644 app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx diff --git a/app/contexts/TerminalCustomizationContext.tsx b/app/contexts/TerminalCustomizationContext.tsx index 14a2c61..2b2c17e 100644 --- a/app/contexts/TerminalCustomizationContext.tsx +++ b/app/contexts/TerminalCustomizationContext.tsx @@ -20,7 +20,7 @@ interface TerminalCustomizationContextType { isLoading: boolean; updateConfig: (config: Partial) => Promise; resetConfig: () => Promise; - // Legacy support + updateFontSize: (fontSize: number) => Promise; resetToDefault: () => Promise; } @@ -78,7 +78,6 @@ export const TerminalCustomizationProvider: React.FC<{ await saveConfig(getDefaultConfig()); }, [saveConfig]); - // Legacy support for existing code const updateFontSize = useCallback( async (fontSize: number) => { await updateConfig({ fontSize }); diff --git a/app/contexts/TerminalSessionsContext.tsx b/app/contexts/TerminalSessionsContext.tsx index 9c52c76..70445c4 100644 --- a/app/contexts/TerminalSessionsContext.tsx +++ b/app/contexts/TerminalSessionsContext.tsx @@ -82,10 +82,13 @@ export const TerminalSessionsProvider: React.FC< ); const typeLabel = - type === "stats" ? "Stats" - : type === "filemanager" ? "Files" - : type === "tunnel" ? "Tunnels" - : ""; + type === "stats" + ? "Stats" + : type === "filemanager" + ? "Files" + : type === "tunnel" + ? "Tunnels" + : ""; let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; if (existingSessions.length > 0) { title = typeLabel @@ -152,8 +155,8 @@ export const TerminalSessionsProvider: React.FC< : session.type === "filemanager" ? "Files" : session.type === "tunnel" - ? "Tunnels" - : ""; + ? "Tunnels" + : ""; const baseName = typeLabel ? `${session.host.name} - ${typeLabel}` : session.host.name; diff --git a/app/main-axios.ts b/app/main-axios.ts index 40f9dde..5aeb01f 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -37,8 +37,6 @@ import { Platform } from "react-native"; const platform = Platform; -// All types are now imported from ../types/index.ts - // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ @@ -1046,6 +1044,8 @@ export async function connectSSH( credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; + overrideCredentialUsername?: boolean; + jumpHosts?: { hostId: number }[]; }, ): Promise { try { diff --git a/app/tabs/dialogs/SSHAuthDialog.tsx b/app/tabs/dialogs/SSHAuthDialog.tsx index 141be33..e0b6d66 100644 --- a/app/tabs/dialogs/SSHAuthDialog.tsx +++ b/app/tabs/dialogs/SSHAuthDialog.tsx @@ -1,6 +1,30 @@ -import React, { useState, useEffect } from 'react'; -import { View, Text, TextInput, TouchableOpacity, Modal, ScrollView } from 'react-native'; -import { BORDERS, BORDER_COLORS, RADIUS } from '@/app/constants/designTokens'; +import React, { + useState, + useEffect, + useCallback, + useMemo, + useRef, +} from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + Modal, + ScrollView, + Platform, + KeyboardAvoidingView, + TouchableWithoutFeedback, +} from "react-native"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding } from "@/app/utils/responsive"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; interface SSHAuthDialogProps { visible: boolean; @@ -16,200 +40,299 @@ interface SSHAuthDialogProps { port: number; username: string; }; - reason: 'no_keyboard' | 'auth_failed' | 'timeout'; + reason: "no_keyboard" | "auth_failed" | "timeout"; } -export const SSHAuthDialog: React.FC = ({ +const SSHAuthDialogComponent: React.FC = ({ visible, onSubmit, onCancel, hostInfo, reason, }) => { - const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password'); - const [password, setPassword] = useState(''); - const [sshKey, setSshKey] = useState(''); - const [keyPassword, setKeyPassword] = useState(''); + const [authMethod, setAuthMethod] = useState<"password" | "key">("password"); + const [password, setPassword] = useState(""); + const [sshKey, setSshKey] = useState(""); + const [keyPassword, setKeyPassword] = useState(""); + const { isLandscape } = useOrientation(); + const insets = useSafeAreaInsets(); + const padding = getResponsivePadding(isLandscape); + const passwordInputRef = useRef(null); + const sshKeyInputRef = useRef(null); - // Clear inputs when dialog closes useEffect(() => { if (!visible) { - setPassword(''); - setSshKey(''); - setKeyPassword(''); - setAuthMethod('password'); + setPassword(""); + setSshKey(""); + setKeyPassword(""); + setAuthMethod("password"); } }, [visible]); - const getReasonMessage = () => { + useEffect(() => { + if (visible) { + const timer = setTimeout(() => { + if (authMethod === "password") { + passwordInputRef.current?.focus(); + } else { + sshKeyInputRef.current?.focus(); + } + }, 300); + return () => clearTimeout(timer); + } + }, [visible, authMethod]); + + const getReasonMessage = useCallback(() => { switch (reason) { - case 'no_keyboard': - return 'Keyboard-interactive authentication is not supported on mobile. Please provide credentials directly.'; - case 'auth_failed': - return 'Authentication failed. Please re-enter your credentials.'; - case 'timeout': - return 'Connection timed out. Please try again with your credentials.'; + case "no_keyboard": + return "Keyboard-interactive authentication is not supported on mobile. Please provide credentials directly."; + case "auth_failed": + return "Authentication failed. Please re-enter your credentials."; + case "timeout": + return "Connection timed out. Please try again with your credentials."; default: - return 'Please provide your credentials to connect.'; + return "Please provide your credentials to connect."; } - }; + }, [reason]); - const handleSubmit = () => { - if (authMethod === 'password' && password.trim()) { + const handleSubmit = useCallback(() => { + if (authMethod === "password" && password.trim()) { onSubmit({ password }); - setPassword(''); - } else if (authMethod === 'key' && sshKey.trim()) { + setPassword(""); + } else if (authMethod === "key" && sshKey.trim()) { onSubmit({ sshKey, keyPassword: keyPassword.trim() || undefined, }); - setSshKey(''); - setKeyPassword(''); + setSshKey(""); + setKeyPassword(""); } - }; + }, [authMethod, password, sshKey, keyPassword, onSubmit]); - const handleCancel = () => { - setPassword(''); - setSshKey(''); - setKeyPassword(''); + const handleCancel = useCallback(() => { + setPassword(""); + setSshKey(""); + setKeyPassword(""); onCancel(); - }; + }, [onCancel]); - const isValid = authMethod === 'password' ? password.trim() : sshKey.trim(); + const handleSetAuthMethod = useCallback((method: "password" | "key") => { + setAuthMethod(method); + }, []); + + const isValid = useMemo( + () => + authMethod === "password" + ? password.trim().length > 0 + : sshKey.trim().length > 0, + [authMethod, password, sshKey], + ); return ( - - + + - + SSH Authentication Required - {/* Host Info */} - - - {hostInfo.name && ( - {hostInfo.name} - )} - {hostInfo.name && '\n'} - - {hostInfo.username}@{hostInfo.ip}:{hostInfo.port} - - - - - {/* Reason Message */} - - + + {getReasonMessage()} - {/* Auth Method Selector */} - + setAuthMethod('password')} - className={`flex-1 py-2 ${ - authMethod === 'password' ? 'bg-blue-500' : 'bg-dark-bg-darker' - }`} + onPress={() => handleSetAuthMethod("password")} style={{ + flex: 1, + paddingVertical: 12, + backgroundColor: + authMethod === "password" ? "#16a34a" : "#1a1a1a", borderWidth: BORDERS.STANDARD, - borderColor: authMethod === 'password' ? '#2563EB' : BORDER_COLORS.BUTTON, + borderColor: + authMethod === "password" + ? "#16a34a" + : BORDER_COLORS.BUTTON, borderRadius: RADIUS.BUTTON, }} activeOpacity={0.7} > - + Password setAuthMethod('key')} - className={`flex-1 py-2 ${ - authMethod === 'key' ? 'bg-blue-500' : 'bg-dark-bg-darker' - }`} + onPress={() => handleSetAuthMethod("key")} style={{ + flex: 1, + paddingVertical: 12, + backgroundColor: authMethod === "key" ? "#16a34a" : "#1a1a1a", borderWidth: BORDERS.STANDARD, - borderColor: authMethod === 'key' ? '#2563EB' : BORDER_COLORS.BUTTON, + borderColor: + authMethod === "key" ? "#16a34a" : BORDER_COLORS.BUTTON, borderRadius: RADIUS.BUTTON, }} activeOpacity={0.7} > - + SSH Key - {/* Password Input */} - {authMethod === 'password' && ( - - Password + {authMethod === "password" && ( + + + Password + )} - {/* SSH Key Inputs */} - {authMethod === 'key' && ( + {authMethod === "key" && ( <> - - Private SSH Key + + + Private SSH Key + - - + + Key Password (optional) = ({ )} - {/* Action Buttons */} - + - + Cancel - + Connect - + ); }; + +export const SSHAuthDialog = React.memo(SSHAuthDialogComponent); diff --git a/app/tabs/dialogs/TOTPDialog.tsx b/app/tabs/dialogs/TOTPDialog.tsx index 3694616..49d8719 100644 --- a/app/tabs/dialogs/TOTPDialog.tsx +++ b/app/tabs/dialogs/TOTPDialog.tsx @@ -1,6 +1,31 @@ -import React, { useState, useEffect } from 'react'; -import { View, Text, TextInput, TouchableOpacity, Modal } from 'react-native'; -import { BORDERS, BORDER_COLORS, RADIUS } from '@/app/constants/designTokens'; +import React, { + useState, + useEffect, + useCallback, + useMemo, + useRef, +} from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + Modal, + ScrollView, + Platform, + KeyboardAvoidingView, + Clipboard, +} from "react-native"; +import { Clipboard as ClipboardIcon } from "lucide-react-native"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding } from "@/app/utils/responsive"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; interface TOTPDialogProps { visible: boolean; @@ -10,103 +35,218 @@ interface TOTPDialogProps { isPasswordPrompt?: boolean; } -export const TOTPDialog: React.FC = ({ +const TOTPDialogComponent: React.FC = ({ visible, onSubmit, onCancel, - prompt = 'Two-Factor Authentication', + prompt = "Two-Factor Authentication", isPasswordPrompt = false, }) => { - const [code, setCode] = useState(''); + const [code, setCode] = useState(""); + const { isLandscape } = useOrientation(); + const insets = useSafeAreaInsets(); + const padding = getResponsivePadding(isLandscape); + const inputRef = useRef(null); - // Clear code when dialog closes useEffect(() => { if (!visible) { - setCode(''); + setCode(""); } }, [visible]); - const handleSubmit = () => { + useEffect(() => { + if (visible) { + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 300); + return () => clearTimeout(timer); + } + }, [visible]); + + const handleSubmit = useCallback(() => { if (code.trim()) { onSubmit(code); - setCode(''); + setCode(""); } - }; + }, [code, onSubmit]); - const handleCancel = () => { - setCode(''); + const handleCancel = useCallback(() => { + setCode(""); onCancel(); - }; + }, [onCancel]); + + const handlePaste = useCallback(async () => { + try { + const clipboardContent = await Clipboard.getString(); + if (clipboardContent) { + const pastedCode = isPasswordPrompt + ? clipboardContent + : clipboardContent.replace(/\D/g, "").slice(0, 6); + setCode(pastedCode); + } + } catch (error) { + console.error("Failed to paste from clipboard:", error); + } + }, [isPasswordPrompt]); + + const isCodeValid = useMemo(() => code.trim().length > 0, [code]); return ( - - - + + - - {prompt} - - - {isPasswordPrompt - ? 'Enter your password to continue' - : 'Enter your TOTP verification code'} - - - - + - - Cancel - - - + - - {isPasswordPrompt ? 'Submit' : 'Verify'} - - + {isPasswordPrompt + ? "Enter your password to continue" + : "Enter your TOTP verification code"} + + + + + + + + + + + Cancel + + + + + {isPasswordPrompt ? "Submit" : "Verify"} + + + - - + + ); }; + +export const TOTPDialog = React.memo(TOTPDialogComponent); diff --git a/app/tabs/dialogs/index.ts b/app/tabs/dialogs/index.ts index 39fca0c..b7bbf81 100644 --- a/app/tabs/dialogs/index.ts +++ b/app/tabs/dialogs/index.ts @@ -1,2 +1,2 @@ -export { TOTPDialog } from './TOTPDialog'; -export { SSHAuthDialog } from './SSHAuthDialog'; +export { TOTPDialog } from "./TOTPDialog"; +export { SSHAuthDialog } from "./SSHAuthDialog"; diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 6b88531..e7591ae 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -6,6 +6,7 @@ import { TouchableWithoutFeedback, Animated, Easing, + ScrollView, } from "react-native"; import { Terminal, @@ -20,6 +21,7 @@ import { import { SSHHost } from "@/types"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import { useEffect, useRef, useState } from "react"; +import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; interface HostProps { host: SSHHost; @@ -34,6 +36,16 @@ function Host({ host, status, isLast = false }: HostProps) { const statusLabel = status === "online" ? "UP" : status === "offline" ? "DOWN" : "UNK"; + const parsedStatsConfig: StatsConfig = (() => { + try { + return host.statsConfig + ? JSON.parse(host.statsConfig) + : DEFAULT_STATS_CONFIG; + } catch { + return DEFAULT_STATS_CONFIG; + } + })(); + const getStatusColor = () => { switch (status) { case "online": @@ -314,7 +326,10 @@ function Host({ host, status, isLast = false }: HostProps) { {}}> - + - - {host.enableTerminal && ( - - - - - Open SSH Terminal - - - {host.ip} - {host.username ? ` • ${host.username}` : ""} - - - - )} - - - - - - View Server Stats - - - Monitor CPU, memory, and disk usage - - - - - {host.enableFileManager && ( - - - - - File Manager - - - Browse and manage files - - - - )} + + + {host.enableTerminal && ( + + + + + Open SSH Terminal + + + {host.ip} + {host.username ? ` • ${host.username}` : ""} + + + + )} - {host.enableTunnel && - host.tunnelConnections && - host.tunnelConnections.length > 0 && ( + {parsedStatsConfig.metricsEnabled && ( { - navigateToSessions(host, "tunnel"); - setShowContextMenu(false); - }} + onPress={handleStatsPress} className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" activeOpacity={0.7} > - Manage Tunnels + View Server Stats - {host.tunnelConnections.length} tunnel - {host.tunnelConnections.length !== 1 ? "s" : ""}{" "} - configured + Monitor CPU, memory, and disk usage )} - - - Close - - + {host.enableFileManager && ( + + + + + File Manager + + + Browse and manage files + + + + )} + + {host.enableTunnel && + host.tunnelConnections && + host.tunnelConnections.length > 0 && ( + { + navigateToSessions(host, "tunnel"); + setShowContextMenu(false); + }} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + + + Manage Tunnels + + + Browse and control SSH tunnels + + + + )} + + + + Close + + + diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 2776e38..500419c 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -20,7 +20,10 @@ import { useFocusEffect } from "@react-navigation/native"; import { useRouter } from "expo-router"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; -import { Terminal, TerminalHandle } from "@/app/tabs/sessions/terminal/Terminal"; +import { + Terminal, + TerminalHandle, +} from "@/app/tabs/sessions/terminal/Terminal"; import { ServerStats, ServerStatsHandle, @@ -84,7 +87,9 @@ export default function Sessions() { ); const [keyboardType, setKeyboardType] = useState("default"); const lastBlurTimeRef = useRef(0); - const [terminalBackgroundColors, setTerminalBackgroundColors] = useState>({}); + const [terminalBackgroundColors, setTerminalBackgroundColors] = useState< + Record + >({}); const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); const effectiveKeyboardHeight = isLandscape @@ -376,9 +381,10 @@ export default function Sessions() { (session) => session.id === activeSessionId, ); - const activeTerminalBgColor = activeSession?.type === "terminal" && activeSessionId - ? terminalBackgroundColors[activeSessionId] || BACKGROUNDS.DARKEST - : BACKGROUNDS.DARKEST; + const activeTerminalBgColor = + activeSession?.type === "terminal" && activeSessionId + ? terminalBackgroundColors[activeSessionId] || BACKGROUNDS.DARKEST + : BACKGROUNDS.DARKEST; return ( { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + const isDialogOpen = + activeRef?.current?.isDialogOpen?.() || false; + if ( !keyboardIntentionallyHiddenRef.current && !isCustomKeyboardVisible && - activeSession?.type === "terminal" + activeSession?.type === "terminal" && + !isDialogOpen ) { requestAnimationFrame(() => { hiddenInputRef.current?.focus(); diff --git a/app/tabs/sessions/file-manager/FileManager.tsx b/app/tabs/sessions/file-manager/FileManager.tsx index 9d1a5e5..9bb50be 100644 --- a/app/tabs/sessions/file-manager/FileManager.tsx +++ b/app/tabs/sessions/file-manager/FileManager.tsx @@ -129,7 +129,10 @@ export const FileManager = forwardRef( keyPassword: host.keyPassword, authType: host.authType, credentialId: host.credentialId, + userId: host.userId, forceKeyboardInteractive: host.forceKeyboardInteractive, + overrideCredentialUsername: host.overrideCredentialUsername, + jumpHosts: host.jumpHosts, }); if (response.requires_totp) { @@ -420,7 +423,6 @@ export const FileManager = forwardRef( }, })); - // Check if file manager is disabled if (!host.enableFileManager) { return ( + - router.navigate("/hosts" as any)} - focusable={false} - className="items-center justify-center" - activeOpacity={0.7} + - - - - - - {sessions.map((session) => { - const isActive = session.id === activeSessionId; - - return ( - onTabPress(session.id)} - focusable={false} - className="flex-row items-center" - style={{ - borderWidth: BORDERS.STANDARD, - borderColor: isActive - ? BORDER_COLORS.ACTIVE - : BORDER_COLORS.BUTTON, - backgroundColor: BACKGROUNDS.CARD, - borderRadius: RADIUS.BUTTON, - shadowColor: isActive - ? BORDER_COLORS.ACTIVE - : "transparent", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: isActive ? 0.2 : 0, - shadowRadius: 4, - elevation: isActive ? 3 : 0, - minWidth: isLandscape ? 100 : 120, - height: buttonSize, - }} - > - - - {session.title} - - - - { - e.stopPropagation(); - onTabClose(session.id); - }} - focusable={false} - className="items-center justify-center" - activeOpacity={0.7} - style={{ - width: isLandscape ? 32 : 36, - height: buttonSize, - borderLeftWidth: BORDERS.STANDARD, - borderLeftColor: isActive - ? BORDER_COLORS.ACTIVE - : BORDER_COLORS.BUTTON, - }} - > - - - - ); - })} - - - - {activeSessionType === "terminal" && !isCustomKeyboardVisible && ( router.navigate("/hosts" as any)} focusable={false} className="items-center justify-center" activeOpacity={0.7} @@ -240,46 +125,175 @@ export default function TabBar({ shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, - marginLeft: isLandscape ? 6 : 8, + marginRight: isLandscape ? 6 : 8, }} > - {keyboardIntentionallyHiddenRef.current ? ( - - ) : ( - - )} + - )} - {activeSessionType === "terminal" && ( - onToggleKeyboard?.()} - focusable={false} - className="items-center justify-center" - activeOpacity={0.7} - style={{ - width: buttonSize, - height: buttonSize, - borderWidth: BORDERS.STANDARD, - borderColor: BORDER_COLORS.BUTTON, - backgroundColor: BACKGROUNDS.BUTTON, - borderRadius: RADIUS.BUTTON, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - marginLeft: isLandscape ? 6 : 8, - }} - > - {isCustomKeyboardVisible ? ( - - ) : ( - - )} - - )} + + + {sessions.map((session) => { + const isActive = session.id === activeSessionId; + + return ( + onTabPress(session.id)} + focusable={false} + className="flex-row items-center" + style={{ + borderWidth: BORDERS.STANDARD, + borderColor: isActive + ? BORDER_COLORS.ACTIVE + : BORDER_COLORS.BUTTON, + backgroundColor: BACKGROUNDS.CARD, + borderRadius: RADIUS.BUTTON, + shadowColor: isActive + ? BORDER_COLORS.ACTIVE + : "transparent", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: isActive ? 0.2 : 0, + shadowRadius: 4, + elevation: isActive ? 3 : 0, + minWidth: isLandscape ? 100 : 120, + height: buttonSize, + }} + > + + + {session.title} + + + + { + e.stopPropagation(); + onTabClose(session.id); + }} + focusable={false} + className="items-center justify-center" + activeOpacity={0.7} + style={{ + width: isLandscape ? 32 : 36, + height: buttonSize, + borderLeftWidth: BORDERS.STANDARD, + borderLeftColor: isActive + ? BORDER_COLORS.ACTIVE + : BORDER_COLORS.BUTTON, + }} + > + + + + ); + })} + + + + {activeSessionType === "terminal" && !isCustomKeyboardVisible && ( + + {keyboardIntentionallyHiddenRef.current ? ( + + ) : ( + + )} + + )} + + {activeSessionType === "terminal" && ( + onToggleKeyboard?.()} + focusable={false} + className="items-center justify-center" + activeOpacity={0.7} + style={{ + width: buttonSize, + height: buttonSize, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, + backgroundColor: BACKGROUNDS.BUTTON, + borderRadius: RADIUS.BUTTON, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + marginLeft: isLandscape ? 6 : 8, + }} + > + {isCustomKeyboardVisible ? ( + + ) : ( + + )} + + )} + + {activeSessionType === "terminal" && isCustomKeyboardVisible && ( + + )} ); } diff --git a/app/tabs/sessions/server-stats/ServerStats.tsx b/app/tabs/sessions/server-stats/ServerStats.tsx index 09d9a48..26852c8 100644 --- a/app/tabs/sessions/server-stats/ServerStats.tsx +++ b/app/tabs/sessions/server-stats/ServerStats.tsx @@ -63,7 +63,7 @@ export const ServerStats = forwardRef( const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); const [executingActions, setExecutingActions] = useState>( - new Set() + new Set(), ); const refreshIntervalRef = useRef(null); @@ -150,24 +150,18 @@ export const ServerStats = forwardRef( const handleQuickAction = async (action: QuickAction) => { setExecutingActions((prev) => new Set(prev).add(action.snippetId)); - showToast.loading(`Executing ${action.name}...`); + showToast.info(`Executing ${action.name}...`); try { const result = await executeSnippet(action.snippetId, hostConfig.id); if (result.success) { - showToast.success(`${action.name} completed`, { - description: result.output?.substring(0, 200), - }); + showToast.success(`${action.name} completed successfully`); } else { - showToast.error(`${action.name} failed`, { - description: result.error || result.output, - }); + showToast.error(`${action.name} failed`); } } catch (error: any) { - showToast.error(`${action.name} error`, { - description: error?.message || "Unknown error", - }); + showToast.error(error?.message || `Failed to execute ${action.name}`); } finally { setExecutingActions((prev) => { const next = new Set(prev); @@ -339,71 +333,62 @@ export const ServerStats = forwardRef( - {/* Quick Actions Section */} - {hostConfig?.quickActions && - hostConfig.quickActions.length > 0 && ( - - - Quick Actions - - - {hostConfig.quickActions.map((action) => { - const isExecuting = executingActions.has( - action.snippetId - ); - return ( - handleQuickAction(action)} - disabled={isExecuting} + {hostConfig?.quickActions && hostConfig.quickActions.length > 0 && ( + + + Quick Actions + + + {hostConfig.quickActions.map((action) => { + const isExecuting = executingActions.has(action.snippetId); + return ( + handleQuickAction(action)} + disabled={isExecuting} + style={{ + backgroundColor: isExecuting ? "#374151" : "#22C55E", + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: RADIUS.BUTTON, + flexDirection: "row", + alignItems: "center", + gap: 8, + opacity: isExecuting ? 0.6 : 1, + }} + activeOpacity={0.7} + > + {isExecuting && ( + + )} + - {isExecuting && ( - - )} - - {action.name} - - - ); - })} - + {action.name} + + + ); + })} - )} + + )} ( gap: 12, }} > - {renderMetricCard( - , - "CPU Usage", - typeof metrics?.cpu?.percent === "number" - ? `${metrics.cpu.percent}%` - : "N/A", - typeof metrics?.cpu?.cores === "number" - ? `${metrics.cpu.cores} cores` - : "N/A", - "#60A5FA", - )} + 1 ? 0 : 12, + width: cardWidth, + }} + > + + + + CPU Usage + + - {metrics?.cpu?.load && ( 1 ? 0 : 12, - width: cardWidth, + flexDirection: "row", + alignItems: "baseline", + gap: 12, + marginBottom: 12, }} > + + {typeof metrics?.cpu?.percent === "number" + ? `${metrics.cpu.percent}%` + : "N/A"} + + + {typeof metrics?.cpu?.cores === "number" + ? `${metrics.cpu.cores} cores` + : "N/A"} + + + + {metrics?.cpu?.load && ( - Load Average - - - - - {metrics.cpu.load[0].toFixed(2)} - - - 1 min - - - - - {metrics.cpu.load[1].toFixed(2)} - - - 5 min - - - - - {metrics.cpu.load[2].toFixed(2)} - - - 15 min - + + + + {metrics.cpu.load[0].toFixed(2)} + + + 1 min + + + + + {metrics.cpu.load[1].toFixed(2)} + + + 5 min + + + + + {metrics.cpu.load[2].toFixed(2)} + + + 15 min + + - - )} + )} + {renderMetricCard( , diff --git a/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx b/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx index 2528621..18da3c2 100644 --- a/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx @@ -1,8 +1,13 @@ -import React from 'react'; -import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; -import { Cpu } from 'lucide-react-native'; -import { ServerMetrics } from '@/types'; -import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; +import React from "react"; +import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; +import { Cpu } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; interface WidgetProps { metrics: ServerMetrics | null; @@ -32,11 +37,11 @@ export const CpuWidget: React.FC = ({ metrics, isLoading }) => { - - {cpuPercent !== null ? `${cpuPercent.toFixed(1)}%` : 'N/A'} + + {cpuPercent !== null ? `${cpuPercent.toFixed(1)}%` : "N/A"} - {cores !== null ? `${cores} cores` : 'N/A'} + {cores !== null ? `${cores} cores` : "N/A"} @@ -69,17 +74,17 @@ export const CpuWidget: React.FC = ({ metrics, isLoading }) => { const styles = StyleSheet.create({ widgetCard: { padding: 16, - position: 'relative', + position: "relative", }, header: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", marginBottom: 12, }, title: { - color: '#ffffff', + color: "#ffffff", fontSize: 16, - fontWeight: '600', + fontWeight: "600", marginLeft: 8, }, metricRow: { @@ -87,40 +92,40 @@ const styles = StyleSheet.create({ }, value: { fontSize: 32, - fontWeight: '700', + fontWeight: "700", marginBottom: 4, }, subtitle: { - color: '#9CA3AF', + color: "#9CA3AF", fontSize: 14, }, loadRow: { - flexDirection: 'row', - justifyContent: 'space-around', + flexDirection: "row", + justifyContent: "space-around", marginTop: 8, }, loadItem: { - alignItems: 'center', + alignItems: "center", }, loadValue: { - color: '#ffffff', + color: "#ffffff", fontSize: 16, - fontWeight: '600', + fontWeight: "600", }, loadLabel: { - color: '#9CA3AF', + color: "#9CA3AF", fontSize: 12, marginTop: 2, }, loadingOverlay: { - position: 'absolute', + position: "absolute", top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', + backgroundColor: "rgba(0, 0, 0, 0.3)", + justifyContent: "center", + alignItems: "center", borderRadius: 12, }, }); diff --git a/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx index f33f1d0..e663730 100644 --- a/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx @@ -1,8 +1,13 @@ -import React from 'react'; -import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; -import { HardDrive } from 'lucide-react-native'; -import { ServerMetrics } from '@/types'; -import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; +import React from "react"; +import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; +import { HardDrive } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; interface WidgetProps { metrics: ServerMetrics | null; @@ -32,13 +37,13 @@ export const DiskWidget: React.FC = ({ metrics, isLoading }) => { - - {diskPercent !== null ? `${diskPercent.toFixed(1)}%` : 'N/A'} + + {diskPercent !== null ? `${diskPercent.toFixed(1)}%` : "N/A"} {usedHuman !== null && totalHuman !== null ? `${usedHuman} / ${totalHuman}` - : 'N/A'} + : "N/A"} @@ -54,17 +59,17 @@ export const DiskWidget: React.FC = ({ metrics, isLoading }) => { const styles = StyleSheet.create({ widgetCard: { padding: 16, - position: 'relative', + position: "relative", }, header: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", marginBottom: 12, }, title: { - color: '#ffffff', + color: "#ffffff", fontSize: 16, - fontWeight: '600', + fontWeight: "600", marginLeft: 8, }, metricRow: { @@ -72,22 +77,22 @@ const styles = StyleSheet.create({ }, value: { fontSize: 32, - fontWeight: '700', + fontWeight: "700", marginBottom: 4, }, subtitle: { - color: '#9CA3AF', + color: "#9CA3AF", fontSize: 14, }, loadingOverlay: { - position: 'absolute', + position: "absolute", top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', + backgroundColor: "rgba(0, 0, 0, 0.3)", + justifyContent: "center", + alignItems: "center", borderRadius: 12, }, }); diff --git a/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx b/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx deleted file mode 100644 index dde2a0a..0000000 --- a/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; -import { Users } from 'lucide-react-native'; -import { ServerMetrics } from '@/types'; -import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; - -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const LoginStatsWidget: React.FC = ({ metrics, isLoading }) => { - // Login stats not yet in ServerMetrics type - // Will show N/A until backend provides this data - - return ( - - - - Login Stats - - - - N/A - Not available yet - - - {isLoading && ( - - - - )} - - ); -}; - -const styles = StyleSheet.create({ - widgetCard: { - padding: 16, - position: 'relative', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 12, - }, - title: { - color: '#ffffff', - fontSize: 16, - fontWeight: '600', - marginLeft: 8, - }, - metricRow: { - marginBottom: 12, - }, - value: { - fontSize: 32, - fontWeight: '700', - marginBottom: 4, - }, - subtitle: { - color: '#9CA3AF', - fontSize: 14, - }, - loadingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: 12, - }, -}); diff --git a/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx index 9705776..58ed401 100644 --- a/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx @@ -1,8 +1,13 @@ -import React from 'react'; -import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; -import { MemoryStick } from 'lucide-react-native'; -import { ServerMetrics } from '@/types'; -import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; +import React from "react"; +import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; +import { MemoryStick } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; interface WidgetProps { metrics: ServerMetrics | null; @@ -32,13 +37,13 @@ export const MemoryWidget: React.FC = ({ metrics, isLoading }) => { - - {memoryPercent !== null ? `${memoryPercent.toFixed(1)}%` : 'N/A'} + + {memoryPercent !== null ? `${memoryPercent.toFixed(1)}%` : "N/A"} {usedGiB !== null && totalGiB !== null ? `${usedGiB.toFixed(2)} / ${totalGiB.toFixed(2)} GiB` - : 'N/A'} + : "N/A"} @@ -54,17 +59,17 @@ export const MemoryWidget: React.FC = ({ metrics, isLoading }) => { const styles = StyleSheet.create({ widgetCard: { padding: 16, - position: 'relative', + position: "relative", }, header: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", marginBottom: 12, }, title: { - color: '#ffffff', + color: "#ffffff", fontSize: 16, - fontWeight: '600', + fontWeight: "600", marginLeft: 8, }, metricRow: { @@ -72,22 +77,22 @@ const styles = StyleSheet.create({ }, value: { fontSize: 32, - fontWeight: '700', + fontWeight: "700", marginBottom: 4, }, subtitle: { - color: '#9CA3AF', + color: "#9CA3AF", fontSize: 14, }, loadingOverlay: { - position: 'absolute', + position: "absolute", top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', + backgroundColor: "rgba(0, 0, 0, 0.3)", + justifyContent: "center", + alignItems: "center", borderRadius: 12, }, }); diff --git a/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx b/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx deleted file mode 100644 index f2a6023..0000000 --- a/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; -import { Network } from 'lucide-react-native'; -import { ServerMetrics } from '@/types'; -import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; - -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const NetworkWidget: React.FC = ({ metrics, isLoading }) => { - // Network metrics not yet in ServerMetrics type - // Will show N/A until backend provides this data - - return ( - - - - Network - - - - N/A - Not available yet - - - {isLoading && ( - - - - )} - - ); -}; - -const styles = StyleSheet.create({ - widgetCard: { - padding: 16, - position: 'relative', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 12, - }, - title: { - color: '#ffffff', - fontSize: 16, - fontWeight: '600', - marginLeft: 8, - }, - metricRow: { - marginBottom: 12, - }, - value: { - fontSize: 32, - fontWeight: '700', - marginBottom: 4, - }, - subtitle: { - color: '#9CA3AF', - fontSize: 14, - }, - loadingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: 12, - }, -}); diff --git a/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx b/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx deleted file mode 100644 index 6e24b0e..0000000 --- a/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; -import { Activity } from 'lucide-react-native'; -import { ServerMetrics } from '@/types'; -import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; - -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const ProcessesWidget: React.FC = ({ metrics, isLoading }) => { - // Process metrics not yet in ServerMetrics type - // Will show N/A until backend provides this data - - return ( - - - - Processes - - - - N/A - Not available yet - - - {isLoading && ( - - - - )} - - ); -}; - -const styles = StyleSheet.create({ - widgetCard: { - padding: 16, - position: 'relative', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 12, - }, - title: { - color: '#ffffff', - fontSize: 16, - fontWeight: '600', - marginLeft: 8, - }, - metricRow: { - marginBottom: 12, - }, - value: { - fontSize: 32, - fontWeight: '700', - marginBottom: 4, - }, - subtitle: { - color: '#9CA3AF', - fontSize: 14, - }, - loadingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: 12, - }, -}); diff --git a/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx b/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx deleted file mode 100644 index 4d4647f..0000000 --- a/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; -import { Server } from 'lucide-react-native'; -import { ServerMetrics } from '@/types'; -import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; - -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const SystemWidget: React.FC = ({ metrics, isLoading }) => { - // System info not yet in ServerMetrics type - // Will show N/A until backend provides this data - - return ( - - - - System Info - - - - N/A - Not available yet - - - {isLoading && ( - - - - )} - - ); -}; - -const styles = StyleSheet.create({ - widgetCard: { - padding: 16, - position: 'relative', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 12, - }, - title: { - color: '#ffffff', - fontSize: 16, - fontWeight: '600', - marginLeft: 8, - }, - metricRow: { - marginBottom: 12, - }, - value: { - fontSize: 32, - fontWeight: '700', - marginBottom: 4, - }, - subtitle: { - color: '#9CA3AF', - fontSize: 14, - }, - loadingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: 12, - }, -}); diff --git a/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx b/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx deleted file mode 100644 index ba46d72..0000000 --- a/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; -import { Clock } from 'lucide-react-native'; -import { ServerMetrics } from '@/types'; -import { BORDERS, BORDER_COLORS, RADIUS, BACKGROUNDS } from '@/app/constants/designTokens'; - -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const UptimeWidget: React.FC = ({ metrics, isLoading }) => { - // Uptime metrics not yet in ServerMetrics type - // Will show N/A until backend provides this data - - return ( - - - - System Uptime - - - - N/A - Not available yet - - - {isLoading && ( - - - - )} - - ); -}; - -const styles = StyleSheet.create({ - widgetCard: { - padding: 16, - position: 'relative', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 12, - }, - title: { - color: '#ffffff', - fontSize: 16, - fontWeight: '600', - marginLeft: 8, - }, - metricRow: { - marginBottom: 12, - }, - value: { - fontSize: 32, - fontWeight: '700', - marginBottom: 4, - }, - subtitle: { - color: '#9CA3AF', - fontSize: 14, - }, - loadingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: 12, - }, -}); diff --git a/app/tabs/sessions/server-stats/widgets/index.ts b/app/tabs/sessions/server-stats/widgets/index.ts index 5b4c0ac..3707803 100644 --- a/app/tabs/sessions/server-stats/widgets/index.ts +++ b/app/tabs/sessions/server-stats/widgets/index.ts @@ -1,8 +1,3 @@ -export { CpuWidget } from './CpuWidget'; -export { MemoryWidget } from './MemoryWidget'; -export { DiskWidget } from './DiskWidget'; -export { NetworkWidget } from './NetworkWidget'; -export { UptimeWidget } from './UptimeWidget'; -export { ProcessesWidget } from './ProcessesWidget'; -export { SystemWidget } from './SystemWidget'; -export { LoginStatsWidget } from './LoginStatsWidget'; +export { CpuWidget } from "./CpuWidget"; +export { MemoryWidget } from "./MemoryWidget"; +export { DiskWidget } from "./DiskWidget"; diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 3a9905d..c8c1a40 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -16,7 +16,12 @@ import { TextInput, } from "react-native"; import { WebView } from "react-native-webview"; -import { getCurrentServerUrl, getCookie, logActivity, getSnippets } from "../../../main-axios"; +import { + getCurrentServerUrl, + getCookie, + logActivity, + getSnippets, +} from "../../../main-axios"; import { showToast } from "../../../utils/toast"; import { useTerminalCustomization } from "../../../contexts/TerminalCustomizationContext"; import { BACKGROUNDS, BORDER_COLORS } from "../../../constants/designTokens"; @@ -49,10 +54,20 @@ interface TerminalProps { export type TerminalHandle = { sendInput: (data: string) => void; fit: () => void; + isDialogOpen: () => boolean; }; const TerminalComponent = forwardRef( - ({ hostConfig, isVisible, title = "Terminal", onClose, onBackgroundColorChange }, ref) => { + ( + { + hostConfig, + isVisible, + title = "Terminal", + onClose, + onBackgroundColorChange, + }, + ref, + ) => { const webViewRef = useRef(null); const { config } = useTerminalCustomization(); const [webViewKey, setWebViewKey] = useState(0); @@ -67,12 +82,12 @@ const TerminalComponent = forwardRef( const [showConnectingOverlay, setShowConnectingOverlay] = useState(true); const [htmlContent, setHtmlContent] = useState(""); const [currentHostId, setCurrentHostId] = useState(null); - const [terminalBackgroundColor, setTerminalBackgroundColor] = useState("#09090b"); + const [terminalBackgroundColor, setTerminalBackgroundColor] = + useState("#09090b"); const connectionTimeoutRef = useRef | null>( null, ); - // TOTP and Auth dialog state const [totpRequired, setTotpRequired] = useState(false); const [totpPrompt, setTotpPrompt] = useState(""); const [isPasswordPrompt, setIsPasswordPrompt] = useState(false); @@ -149,37 +164,32 @@ const TerminalComponent = forwardRef( `; } - // Merge terminal config (host config > global config > defaults) const terminalConfig: Partial = { ...MOBILE_DEFAULT_TERMINAL_CONFIG, ...config, ...hostConfig.terminalConfig, }; - // Use user's custom fontSize from context, not from API const baseFontSize = config.fontSize || 16; const charWidth = baseFontSize * 0.6; const lineHeight = baseFontSize * 1.2; const terminalWidth = Math.floor(width / charWidth); const terminalHeight = Math.floor(height / lineHeight); - // Get theme colors const themeName = terminalConfig.theme || "termix"; - const themeColors = TERMINAL_THEMES[themeName]?.colors || TERMINAL_THEMES.termix.colors; + const themeColors = + TERMINAL_THEMES[themeName]?.colors || TERMINAL_THEMES.termix.colors; - // Update background color state and notify parent const bgColor = themeColors.background; setTerminalBackgroundColor(bgColor); if (onBackgroundColorChange) { onBackgroundColorChange(bgColor); } - // Get font family const fontConfig = TERMINAL_FONTS.find( - (f) => f.value === terminalConfig.fontFamily + (f) => f.value === terminalConfig.fontFamily, ); - const fontFamily = - fontConfig?.fallback || TERMINAL_FONTS[0].fallback; + const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; return ` @@ -192,38 +202,6 @@ const TerminalComponent = forwardRef( @@ -344,9 +352,9 @@ const TerminalComponent = forwardRef( terminal.loadAddon(fitAddon); terminal.open(document.getElementById('terminal')); - + fitAddon.fit(); - + const hostConfig = ${JSON.stringify(hostConfig)}; const wsUrl = '${wsUrl}'; @@ -364,7 +372,6 @@ const TerminalComponent = forwardRef( let lastPongTime = Date.now(); let lastPingSentTime = null; - // Track all timeouts for proper cleanup let activeTimeouts = []; function safeSetTimeout(fn, delay) { @@ -405,33 +412,39 @@ const TerminalComponent = forwardRef( function scheduleReconnect() { if (shouldNotReconnect) return; + if (ws && ws.readyState === WebSocket.OPEN) { + return; + } + if (reconnectAttempts >= maxReconnectAttempts) { notifyFailureOnce('Maximum reconnection attempts reached'); return; } - reconnectAttempts += 1; // Always increment + reconnectAttempts += 1; const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 5000); - // Always show "reconnecting" when retry count > 0 notifyConnectionState('connecting', { retryCount: reconnectAttempts }); - // Clear any existing reconnect timeout before scheduling new one if (reconnectTimeout) { clearTimeout(reconnectTimeout); } reconnectTimeout = safeSetTimeout(() => { - reconnectTimeout = null; // Clear reference when firing + reconnectTimeout = null; + + if (ws && ws.readyState === WebSocket.OPEN) { + return; + } + connectWebSocket(); }, delay); } window.nativeInput = function(data) { - console.log('[INPUT]', JSON.stringify(data), 'length:', data.length); try { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data: data })); @@ -445,7 +458,6 @@ const TerminalComponent = forwardRef( const terminalElement = document.getElementById('terminal'); - // Function to reset scroll if it breaks (callable from React Native) window.resetScroll = function() { terminal.scrollToBottom(); notifyConnectionState('scrollReset', {}); @@ -455,11 +467,23 @@ const TerminalComponent = forwardRef( if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) { e.preventDefault(); e.stopPropagation(); - setTimeout(function() { - if (e.target && e.target.blur) { - e.target.blur(); - } - }, 0); + e.stopImmediatePropagation(); + if (e.target && e.target.blur) { + e.target.blur(); + } + return false; + } + }, true); + + document.addEventListener('focus', function(e) { + if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + if (e.target && e.target.blur) { + e.target.blur(); + } + return false; } }, true); @@ -469,6 +493,67 @@ const TerminalComponent = forwardRef( return false; }, { passive: false }); + let selectionEndTimeout = null; + let isCurrentlySelecting = false; + let lastInteractionTime = Date.now(); + let keyboardWasVisibleBeforeSelection = false; + + terminalElement.addEventListener('touchstart', (e) => { + lastInteractionTime = Date.now(); + if (!isCurrentlySelecting) { + notifyConnectionState('selectionStart', {}); + isCurrentlySelecting = true; + } + }, { passive: true }); + + terminalElement.addEventListener('mousedown', (e) => { + lastInteractionTime = Date.now(); + if (!isCurrentlySelecting) { + notifyConnectionState('selectionStart', {}); + isCurrentlySelecting = true; + } + }); + + terminalElement.addEventListener('touchend', () => { + lastInteractionTime = Date.now(); + checkIfDoneSelecting(); + }); + + terminalElement.addEventListener('mouseup', () => { + lastInteractionTime = Date.now(); + checkIfDoneSelecting(); + }); + + function checkIfDoneSelecting() { + if (selectionEndTimeout) { + clearTimeout(selectionEndTimeout); + } + + selectionEndTimeout = setTimeout(() => { + const selection = terminal.getSelection(); + const hasSelection = selection && selection.length > 0; + const timeSinceLastInteraction = Date.now() - lastInteractionTime; + + if (!hasSelection && timeSinceLastInteraction >= 150) { + isCurrentlySelecting = false; + notifyConnectionState('selectionEnd', {}); + } else if (hasSelection) { + checkIfDoneSelecting(); + } else { + checkIfDoneSelecting(); + } + }, 200); + } + + terminal.onSelectionChange(() => { + const selection = terminal.getSelection(); + const hasSelection = selection && selection.length > 0; + + if (!hasSelection && isCurrentlySelecting) { + checkIfDoneSelecting(); + } + }); + function connectWebSocket() { try { if (!wsUrl) { @@ -476,15 +561,19 @@ const TerminalComponent = forwardRef( return; } - // Close old WebSocket if it exists to prevent old event handlers from firing + if (ws && ws.readyState === WebSocket.OPEN) { + return; + } + if (ws) { try { - // Remove event handlers from old WebSocket to prevent them from triggering ws.onclose = null; ws.onerror = null; ws.onmessage = null; ws.onopen = null; - ws.close(); + if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) { + ws.close(); + } } catch (e) { console.error('[CONNECT] Error closing old WebSocket:', e); } @@ -497,9 +586,8 @@ const TerminalComponent = forwardRef( connectionTimeout = safeSetTimeout(() => { if (ws && ws.readyState === WebSocket.CONNECTING) { - console.log('[CONNECT] Connection timeout after 10s'); try { - ws.onclose = null; // Prevent onclose from triggering scheduleReconnect again + ws.onclose = null; ws.close(); } catch (_) {} if (!shouldNotReconnect && reconnectAttempts < maxReconnectAttempts) { @@ -508,16 +596,15 @@ const TerminalComponent = forwardRef( notifyFailureOnce('Connection timeout - server not responding'); } } - }, 10000); // Reduced from 30s to 10s + }, 10000); ws.onopen = function() { - // Clear all timeouts to prevent any pending reconnects from firing clearTimeout(connectionTimeout); if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } - clearAllTimeouts(); // Clear any other pending timeouts + clearAllTimeouts(); hasNotifiedFailure = false; reconnectAttempts = 0; @@ -582,7 +669,6 @@ const TerminalComponent = forwardRef( lastPongTime = Date.now(); connectionHealthy = true; - // If we're checking health and got a pong, notify immediately if (lastPingSentTime && (Date.now() - lastPingSentTime < 15000)) { notifyConnectionState('connectionHealthy', {}); } @@ -597,24 +683,20 @@ const TerminalComponent = forwardRef( clearTimeout(connectionTimeout); stopPingInterval(); - // If we're in background, this is an intentional close - don't notify failure if (isAppInBackground) { return; } - // If shouldNotReconnect is set and we're NOT in background, it's an auth error if (shouldNotReconnect) { notifyFailureOnce('Connection closed'); return; } - // Normal close codes during foreground operation should not reconnect if (event.code === 1000 || event.code === 1001) { notifyFailureOnce('Connection closed'); return; } - // Unexpected disconnection - try to reconnect scheduleReconnect(); }; @@ -646,20 +728,16 @@ const TerminalComponent = forwardRef( } } - // Background/foreground handlers window.notifyBackgrounded = function() { isAppInBackground = true; backgroundTime = Date.now(); - // Reset retry counter when going to background - fresh start on foreground reconnectAttempts = 0; - // Stop ping interval to prevent failed attempts stopPingInterval(); - // Close WebSocket cleanly to avoid zombie connections if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { - shouldNotReconnect = true; // Prevent auto-reconnect during background + shouldNotReconnect = true; try { ws.close(1000, 'App backgrounded'); } catch(e) { @@ -669,7 +747,6 @@ const TerminalComponent = forwardRef( window.ws = null; } - // Clear all pending timeouts clearAllTimeouts(); notifyConnectionState('backgrounded', { closed: true }); @@ -678,22 +755,18 @@ const TerminalComponent = forwardRef( window.notifyForegrounded = function() { const wasInBackground = isAppInBackground; isAppInBackground = false; - shouldNotReconnect = false; // Re-enable reconnection + shouldNotReconnect = false; if (wasInBackground) { const backgroundDuration = Date.now() - (backgroundTime || 0); - // Don't reset reconnectAttempts - continue from where we were - // This ensures proper retry counter display - notifyConnectionState('foregrounded', { duration: backgroundDuration, reconnecting: true }); - // Small delay for smooth UI transition, then reconnect safeSetTimeout(() => { - scheduleReconnect(); // Use scheduleReconnect instead of direct connectWebSocket + scheduleReconnect(); }, 100); } } @@ -885,7 +958,10 @@ const TerminalComponent = forwardRef( 100 * (terminalConfig.environmentVariables?.length || 0) + (terminalConfig.startupSnippetId ? 400 : 200); setTimeout(() => { - const moshCommand = terminalConfig.moshCommand!.replace(/'/g, "\\'"); + const moshCommand = terminalConfig.moshCommand!.replace( + /'/g, + "\\'", + ); webViewRef.current?.injectJavaScript(` if (window.ws && window.ws.readyState === WebSocket.OPEN) { window.ws.send(JSON.stringify({ @@ -907,15 +983,19 @@ const TerminalComponent = forwardRef( switch (message.type) { case "connecting": - setConnectionState(message.data.retryCount > 0 ? 'reconnecting' : 'connecting'); + setConnectionState( + message.data.retryCount > 0 ? "reconnecting" : "connecting", + ); setRetryCount(message.data.retryCount); break; case "connected": - setConnectionState('connected'); + setConnectionState("connected"); setRetryCount(0); setHasReceivedData(false); - logActivity("terminal", hostConfig.id, hostConfig.name).catch(() => {}); + logActivity("terminal", hostConfig.id, hostConfig.name).catch( + () => {}, + ); break; case "totpRequired": @@ -927,7 +1007,7 @@ const TerminalComponent = forwardRef( case "authDialogNeeded": setAuthDialogReason(message.data.reason); setShowAuthDialog(true); - setConnectionState('disconnected'); + setConnectionState("disconnected"); break; case "setupPostConnection": @@ -939,35 +1019,47 @@ const TerminalComponent = forwardRef( break; case "disconnected": - setConnectionState('disconnected'); + setConnectionState("disconnected"); showToast.warning(`Disconnected from ${message.data.hostName}`); if (onClose) onClose(); break; case "connectionFailed": - setConnectionState('failed'); - handleConnectionFailure(`${message.data.hostName}: ${message.data.message}`); + setConnectionState("failed"); + handleConnectionFailure( + `${message.data.hostName}: ${message.data.message}`, + ); break; case "backgrounded": - // App went to background, WebSocket closed cleanly - setConnectionState('disconnected'); + setConnectionState("disconnected"); break; case "foregrounded": - // App returned to foreground, reconnecting - setConnectionState('reconnecting'); + setConnectionState("reconnecting"); + break; + + case "selectionStart": + setIsSelecting(true); + break; + + case "selectionEnd": + setIsSelecting(false); break; case "connectionStatus": - // Ignore - no longer used break; } } catch (error) { console.error("[Terminal] Error parsing WebView message:", error); } }, - [handleConnectionFailure, onClose, hostConfig.id, handlePostConnectionSetup], + [ + handleConnectionFailure, + onClose, + hostConfig.id, + handlePostConnectionSetup, + ], ); useImperativeHandle( @@ -1015,15 +1107,18 @@ const TerminalComponent = forwardRef( `); } catch (e) {} }, + isSelecting: () => { + return isSelecting; + }, }), - [totpRequired, showAuthDialog], + [totpRequired, showAuthDialog, isSelecting], ); useEffect(() => { if (hostConfig.id !== currentHostId) { setCurrentHostId(hostConfig.id); setWebViewKey((prev) => prev + 1); - setConnectionState('connecting'); + setConnectionState("connecting"); setHasReceivedData(false); setRetryCount(0); @@ -1037,7 +1132,6 @@ const TerminalComponent = forwardRef( useEffect(() => { return () => { - // Notify WebView to clean up all resources webViewRef.current?.injectJavaScript(` (function() { try { @@ -1054,7 +1148,6 @@ const TerminalComponent = forwardRef( true; `); - // Clear React-side timeouts if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; @@ -1102,7 +1195,8 @@ const TerminalComponent = forwardRef( width: "100%", height: "100%", backgroundColor: terminalBackgroundColor, - opacity: connectionState === 'connected' && hasReceivedData ? 1 : 0, + opacity: + connectionState === "connected" && hasReceivedData ? 1 : 0, }} javaScriptEnabled={true} domStorageEnabled={true} @@ -1115,10 +1209,7 @@ const TerminalComponent = forwardRef( cacheEnabled={false} cacheMode="LOAD_NO_CACHE" androidLayerType="hardware" - onScroll={(event) => { - // Track scroll position for debugging if needed - // Allows normal scrolling when not selecting - }} + onScroll={(event) => {}} scrollEventThrottle={16} onMessage={handleWebViewMessage} onError={(syntheticEvent) => { @@ -1142,7 +1233,8 @@ const TerminalComponent = forwardRef( />
- {(connectionState === 'connecting' || connectionState === 'reconnecting') && ( + {(connectionState === "connecting" || + connectionState === "reconnecting") && ( ( textAlign: "center", }} > - {connectionState === 'reconnecting' ? 'Reconnecting...' : 'Connecting...'} + {connectionState === "reconnecting" + ? "Reconnecting..." + : "Connecting..."} { return withInfoPlist(config, (config) => { const existingPlist = config.modResults; + + // Configure App Transport Security to allow all HTTP connections + // This is required for both WebView and native networking (Axios) + // Users may connect to self-hosted servers via HTTP on any domain/IP existingPlist.NSAppTransportSecurity = { + // Allow all HTTP loads for user-provided servers NSAllowsArbitraryLoads: true, + // Allow local network connections (LAN, Tailscale, etc.) NSAllowsLocalNetworking: true, + // Allow HTTP in WebView content NSAllowsArbitraryLoadsInWebContent: true, + // Allow HTTP for media NSAllowsArbitraryLoadsForMedia: true, }; From 6be099da61d2284beaeab0a6c62b2af5be95d6c2 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 15 Dec 2025 01:50:08 -0600 Subject: [PATCH 22/27] fix: http on ios --- app.json | 6 +----- app/main-axios.ts | 1 + plugins/withIOSNetworkSecurity.js | 17 ++++------------- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/app.json b/app.json index d8e38f9..2f7afa1 100644 --- a/app.json +++ b/app.json @@ -20,11 +20,7 @@ }, "infoPlist": { "CFBundleAllowMixedLocalizations": true, - "ITSAppUsesNonExemptEncryption": false, - "NSAppTransportSecurity": { - "NSAllowsArbitraryLoads": true, - "NSAllowsLocalNetworking": true - } + "ITSAppUsesNonExemptEncryption": false } }, "android": { diff --git a/app/main-axios.ts b/app/main-axios.ts index 5aeb01f..7f54a07 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -88,6 +88,7 @@ function createApiInstance( baseURL, headers: { "Content-Type": "application/json" }, timeout: 30000, + ...(platform.OS === "ios" && { adapter: "fetch" }), }); instance.interceptors.request.use(async (config) => { diff --git a/plugins/withIOSNetworkSecurity.js b/plugins/withIOSNetworkSecurity.js index 03c0488..e2d94f6 100644 --- a/plugins/withIOSNetworkSecurity.js +++ b/plugins/withIOSNetworkSecurity.js @@ -2,26 +2,17 @@ const { withInfoPlist } = require("@expo/config-plugins"); const withIOSNetworkSecurity = (config) => { return withInfoPlist(config, (config) => { - const existingPlist = config.modResults; - - // Configure App Transport Security to allow all HTTP connections - // This is required for both WebView and native networking (Axios) - // Users may connect to self-hosted servers via HTTP on any domain/IP - existingPlist.NSAppTransportSecurity = { - // Allow all HTTP loads for user-provided servers + config.modResults.NSAppTransportSecurity = { NSAllowsArbitraryLoads: true, - // Allow local network connections (LAN, Tailscale, etc.) - NSAllowsLocalNetworking: true, - // Allow HTTP in WebView content NSAllowsArbitraryLoadsInWebContent: true, - // Allow HTTP for media + NSAllowsLocalNetworking: true, NSAllowsArbitraryLoadsForMedia: true, }; - existingPlist.NSLocalNetworkUsageDescription = + config.modResults.NSLocalNetworkUsageDescription = "Termix needs to connect to servers to load hosts and initiate SSH connections"; - existingPlist.NSBonjourServices = ["_ssh._tcp", "_sftp-ssh._tcp"]; + config.modResults.NSBonjourServices = ["_ssh._tcp", "_sftp-ssh._tcp"]; return config; }); From 61d5f2c5bc002d77055e7be0b4b859d7f1bc7d72 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 15 Dec 2025 13:32:20 -0600 Subject: [PATCH 23/27] fix: http on ios --- app/main-axios.ts | 3 ++- package-lock.json | 11 +++++++++++ package.json | 1 + plugins/withIOSNetworkSecurity.js | 6 ++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/main-axios.ts b/app/main-axios.ts index 7f54a07..9996759 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -1,4 +1,5 @@ import axios, { AxiosError, type AxiosInstance } from "axios"; +import fetchAdapter from "axios-fetch-adapter"; import type { SSHHost, SSHHostData, @@ -88,7 +89,7 @@ function createApiInstance( baseURL, headers: { "Content-Type": "application/json" }, timeout: 30000, - ...(platform.OS === "ios" && { adapter: "fetch" }), + ...(platform.OS === "ios" && { adapter: fetchAdapter }), }); instance.interceptors.request.use(async (config) => { diff --git a/package-lock.json b/package-lock.json index b1f208b..7f01ac3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "axios": "^1.12.2", + "axios-fetch-adapter": "^1.0.0", "commitlint": "^20.1.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", @@ -5176,12 +5177,22 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-fetch-adapter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/axios-fetch-adapter/-/axios-fetch-adapter-1.0.0.tgz", + "integrity": "sha512-OuYRqx6XEfeByYatPKAoXRV48id7qmVkXjfTw8rFUHjwM4Yr8UNiihg7FTx9ZowtUWkq/QkzgFAopgnt26h9xA==", + "license": "MIT", + "peerDependencies": { + "axios": "*" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", diff --git a/package.json b/package.json index fe10eb2..73d5083 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "axios": "^1.12.2", + "axios-fetch-adapter": "^1.0.0", "commitlint": "^20.1.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", diff --git a/plugins/withIOSNetworkSecurity.js b/plugins/withIOSNetworkSecurity.js index e2d94f6..ce54eb0 100644 --- a/plugins/withIOSNetworkSecurity.js +++ b/plugins/withIOSNetworkSecurity.js @@ -7,6 +7,12 @@ const withIOSNetworkSecurity = (config) => { NSAllowsArbitraryLoadsInWebContent: true, NSAllowsLocalNetworking: true, NSAllowsArbitraryLoadsForMedia: true, + NSExceptionDomains: { + localhost: { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + }, }; config.modResults.NSLocalNetworkUsageDescription = From 135a49b1aa51de4c48470bcb6060db25ea868b8f Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 15 Dec 2025 14:26:26 -0600 Subject: [PATCH 24/27] fix: build error with axios fetch --- app/main-axios.ts | 28 ++++++++++++++++++++++++++-- package-lock.json | 11 ----------- package.json | 1 - 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/app/main-axios.ts b/app/main-axios.ts index 9996759..3d6f80e 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -1,5 +1,4 @@ import axios, { AxiosError, type AxiosInstance } from "axios"; -import fetchAdapter from "axios-fetch-adapter"; import type { SSHHost, SSHHostData, @@ -89,7 +88,32 @@ function createApiInstance( baseURL, headers: { "Content-Type": "application/json" }, timeout: 30000, - ...(platform.OS === "ios" && { adapter: fetchAdapter }), + ...(platform.OS === "ios" && { + adapter: async (config) => { + const url = axios.getUri(config); + const token = await getCookie("jwt"); + + const response = await fetch(url, { + method: config.method?.toUpperCase() || "GET", + headers: { + ...config.headers, + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: config.data ? JSON.stringify(config.data) : undefined, + }); + + const data = await response.json(); + + return { + data, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + config, + request: {}, + }; + }, + }), }); instance.interceptors.request.use(async (config) => { diff --git a/package-lock.json b/package-lock.json index 7f01ac3..b1f208b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "axios": "^1.12.2", - "axios-fetch-adapter": "^1.0.0", "commitlint": "^20.1.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", @@ -5177,22 +5176,12 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, - "node_modules/axios-fetch-adapter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/axios-fetch-adapter/-/axios-fetch-adapter-1.0.0.tgz", - "integrity": "sha512-OuYRqx6XEfeByYatPKAoXRV48id7qmVkXjfTw8rFUHjwM4Yr8UNiihg7FTx9ZowtUWkq/QkzgFAopgnt26h9xA==", - "license": "MIT", - "peerDependencies": { - "axios": "*" - } - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", diff --git a/package.json b/package.json index 73d5083..fe10eb2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "axios": "^1.12.2", - "axios-fetch-adapter": "^1.0.0", "commitlint": "^20.1.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", From b3dd300057ab21469b60b57c1ef836231b35dba3 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 15 Dec 2025 16:26:51 -0600 Subject: [PATCH 25/27] feat: improe scrolling and htpp on ios --- app.json | 5 +- app/main-axios.ts | 26 ----- app/tabs/hosts/navigation/Host.tsx | 6 +- app/tabs/sessions/Sessions.tsx | 2 +- app/tabs/sessions/terminal/Terminal.tsx | 105 ++++++++++++++++---- app/tabs/settings/TerminalCustomization.tsx | 5 +- plugins/withIOSNetworkSecurity.js | 37 ++++++- 7 files changed, 131 insertions(+), 55 deletions(-) diff --git a/app.json b/app.json index 2f7afa1..d5713bc 100644 --- a/app.json +++ b/app.json @@ -57,12 +57,13 @@ "usesCleartextTraffic": true }, "ios": { - "newArchEnabled": true + "newArchEnabled": true, + "deploymentTarget": "15.1" } } ], - "./plugins/withNetworkSecurityConfig.js", "./plugins/withIOSNetworkSecurity.js", + "./plugins/withNetworkSecurityConfig.js", "expo-dev-client" ], "experiments": { diff --git a/app/main-axios.ts b/app/main-axios.ts index 3d6f80e..5aeb01f 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -88,32 +88,6 @@ function createApiInstance( baseURL, headers: { "Content-Type": "application/json" }, timeout: 30000, - ...(platform.OS === "ios" && { - adapter: async (config) => { - const url = axios.getUri(config); - const token = await getCookie("jwt"); - - const response = await fetch(url, { - method: config.method?.toUpperCase() || "GET", - headers: { - ...config.headers, - ...(token && { Authorization: `Bearer ${token}` }), - }, - body: config.data ? JSON.stringify(config.data) : undefined, - }); - - const data = await response.json(); - - return { - data, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - config, - request: {}, - }; - }, - }), }); instance.interceptors.request.use(async (config) => { diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index e7591ae..8407bab 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -22,6 +22,7 @@ import { SSHHost } from "@/types"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import { useEffect, useRef, useState } from "react"; import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; interface HostProps { host: SSHHost; @@ -31,6 +32,7 @@ interface HostProps { function Host({ host, status, isLast = false }: HostProps) { const { navigateToSessions } = useTerminalSessions(); + const insets = useSafeAreaInsets(); const [showContextMenu, setShowContextMenu] = useState(false); const [tagsContainerWidth, setTagsContainerWidth] = useState(0); const statusLabel = @@ -327,8 +329,8 @@ function Host({ host, status, isLast = false }: HostProps) { {}}> diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 6088d98..15c7d4c 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -636,7 +636,7 @@ export default function Sessions() { marginBottom: 20, }} > - Connect to a host from the Hosts tab to start a terminal session + Connect to a host from the Hosts tab to start a session ( let selectionEndTimeout = null; let isCurrentlySelecting = false; let lastInteractionTime = Date.now(); - let keyboardWasVisibleBeforeSelection = false; + let touchStartTime = 0; + let touchStartX = 0; + let touchStartY = 0; + let hasMoved = false; + let longPressTimeout = null; terminalElement.addEventListener('touchstart', (e) => { lastInteractionTime = Date.now(); - if (!isCurrentlySelecting) { - notifyConnectionState('selectionStart', {}); - isCurrentlySelecting = true; + touchStartTime = Date.now(); + hasMoved = false; + + if (e.touches && e.touches.length > 0) { + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + } + + if (longPressTimeout) { + clearTimeout(longPressTimeout); } + + longPressTimeout = setTimeout(() => { + if (!hasMoved) { + if (!isCurrentlySelecting) { + notifyConnectionState('selectionStart', {}); + isCurrentlySelecting = true; + } + } + }, 350); }, { passive: true }); - terminalElement.addEventListener('mousedown', (e) => { - lastInteractionTime = Date.now(); - if (!isCurrentlySelecting) { - notifyConnectionState('selectionStart', {}); - isCurrentlySelecting = true; + terminalElement.addEventListener('touchmove', (e) => { + if (e.touches && e.touches.length > 0) { + const deltaX = Math.abs(e.touches[0].clientX - touchStartX); + const deltaY = Math.abs(e.touches[0].clientY - touchStartY); + + if (deltaX > 10 || deltaY > 10) { + hasMoved = true; + if (longPressTimeout) { + clearTimeout(longPressTimeout); + longPressTimeout = null; + } + } } - }); + }, { passive: true }); terminalElement.addEventListener('touchend', () => { + if (longPressTimeout) { + clearTimeout(longPressTimeout); + longPressTimeout = null; + } + + const touchDuration = Date.now() - touchStartTime; + + setTimeout(() => { + const selection = terminal.getSelection(); + const hasSelection = selection && selection.length > 0; + + if (hasSelection) { + lastInteractionTime = Date.now(); + if (!isCurrentlySelecting) { + isCurrentlySelecting = true; + notifyConnectionState('selectionStart', {}); + } + } else if (!isCurrentlySelecting && (touchDuration < 350 || hasMoved)) { + lastInteractionTime = Date.now(); + checkIfDoneSelecting(); + } + }, 100); + }); + + terminalElement.addEventListener('mousedown', (e) => { lastInteractionTime = Date.now(); - checkIfDoneSelecting(); }); terminalElement.addEventListener('mouseup', () => { @@ -532,24 +583,36 @@ const TerminalComponent = forwardRef( selectionEndTimeout = setTimeout(() => { const selection = terminal.getSelection(); const hasSelection = selection && selection.length > 0; - const timeSinceLastInteraction = Date.now() - lastInteractionTime; - if (!hasSelection && timeSinceLastInteraction >= 150) { - isCurrentlySelecting = false; - notifyConnectionState('selectionEnd', {}); - } else if (hasSelection) { - checkIfDoneSelecting(); - } else { - checkIfDoneSelecting(); + if (hasSelection) { + if (!isCurrentlySelecting) { + isCurrentlySelecting = true; + notifyConnectionState('selectionStart', {}); + } + } else if (isCurrentlySelecting) { + const timeSinceLastInteraction = Date.now() - lastInteractionTime; + if (timeSinceLastInteraction >= 150) { + isCurrentlySelecting = false; + notifyConnectionState('selectionEnd', {}); + } else { + checkIfDoneSelecting(); + } } - }, 200); + }, 100); } terminal.onSelectionChange(() => { const selection = terminal.getSelection(); const hasSelection = selection && selection.length > 0; - if (!hasSelection && isCurrentlySelecting) { + if (hasSelection) { + lastInteractionTime = Date.now(); + if (!isCurrentlySelecting) { + isCurrentlySelecting = true; + notifyConnectionState('selectionStart', {}); + } + } else if (isCurrentlySelecting) { + lastInteractionTime = Date.now(); checkIfDoneSelecting(); } }); diff --git a/app/tabs/settings/TerminalCustomization.tsx b/app/tabs/settings/TerminalCustomization.tsx index feabcd5..1170107 100644 --- a/app/tabs/settings/TerminalCustomization.tsx +++ b/app/tabs/settings/TerminalCustomization.tsx @@ -90,7 +90,10 @@ export default function TerminalCustomization() { - + Terminal Settings diff --git a/plugins/withIOSNetworkSecurity.js b/plugins/withIOSNetworkSecurity.js index ce54eb0..d38d5f4 100644 --- a/plugins/withIOSNetworkSecurity.js +++ b/plugins/withIOSNetworkSecurity.js @@ -1,7 +1,9 @@ -const { withInfoPlist } = require("@expo/config-plugins"); +const { withInfoPlist, withDangerousMod } = require("@expo/config-plugins"); +const fs = require("fs"); +const path = require("path"); const withIOSNetworkSecurity = (config) => { - return withInfoPlist(config, (config) => { + config = withInfoPlist(config, (config) => { config.modResults.NSAppTransportSecurity = { NSAllowsArbitraryLoads: true, NSAllowsArbitraryLoadsInWebContent: true, @@ -22,6 +24,37 @@ const withIOSNetworkSecurity = (config) => { return config; }); + + config = withDangerousMod(config, [ + "ios", + async (config) => { + const podfilePath = path.join(config.modRequest.platformProjectRoot, "Podfile"); + + if (fs.existsSync(podfilePath)) { + let podfileContent = fs.readFileSync(podfilePath, "utf8"); + + const atsConfig = ` + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)'] + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'RCT_DEV=1' + end + end + end +`; + + if (!podfileContent.includes("post_install do |installer|")) { + podfileContent += atsConfig; + fs.writeFileSync(podfilePath, podfileContent); + } + } + + return config; + }, + ]); + + return config; }; module.exports = withIOSNetworkSecurity; \ No newline at end of file From 539251d591e5f32661008435979fdec9ab207f17 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 16 Dec 2025 01:01:44 -0600 Subject: [PATCH 26/27] fix: ios http --- plugins/withIOSNetworkSecurity.js | 49 +++++++++++-------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/plugins/withIOSNetworkSecurity.js b/plugins/withIOSNetworkSecurity.js index d38d5f4..7509b2b 100644 --- a/plugins/withIOSNetworkSecurity.js +++ b/plugins/withIOSNetworkSecurity.js @@ -1,20 +1,12 @@ -const { withInfoPlist, withDangerousMod } = require("@expo/config-plugins"); +const { withInfoPlist, withDangerousMod, IOSConfig } = require("@expo/config-plugins"); const fs = require("fs"); -const path = require("path"); const withIOSNetworkSecurity = (config) => { config = withInfoPlist(config, (config) => { + delete config.modResults.NSAppTransportSecurity; + config.modResults.NSAppTransportSecurity = { NSAllowsArbitraryLoads: true, - NSAllowsArbitraryLoadsInWebContent: true, - NSAllowsLocalNetworking: true, - NSAllowsArbitraryLoadsForMedia: true, - NSExceptionDomains: { - localhost: { - NSExceptionAllowsInsecureHTTPLoads: true, - NSIncludesSubdomains: true, - }, - }, }; config.modResults.NSLocalNetworkUsageDescription = @@ -28,26 +20,19 @@ const withIOSNetworkSecurity = (config) => { config = withDangerousMod(config, [ "ios", async (config) => { - const podfilePath = path.join(config.modRequest.platformProjectRoot, "Podfile"); - - if (fs.existsSync(podfilePath)) { - let podfileContent = fs.readFileSync(podfilePath, "utf8"); - - const atsConfig = ` - post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)'] - config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'RCT_DEV=1' - end - end - end -`; - - if (!podfileContent.includes("post_install do |installer|")) { - podfileContent += atsConfig; - fs.writeFileSync(podfilePath, podfileContent); - } + const { platformProjectRoot } = config.modRequest; + + try { + let infoPlist = IOSConfig.InfoPlist.read(platformProjectRoot); + + delete infoPlist.NSAppTransportSecurity; + + infoPlist.NSAppTransportSecurity = { + NSAllowsArbitraryLoads: true, + }; + + IOSConfig.InfoPlist.write(platformProjectRoot, infoPlist); + } catch (e) { } return config; @@ -57,4 +42,4 @@ const withIOSNetworkSecurity = (config) => { return config; }; -module.exports = withIOSNetworkSecurity; \ No newline at end of file +module.exports = withIOSNetworkSecurity; From 7d2d3e89dffd8f81bd7b8aba1679eb8544d97b71 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 16 Dec 2025 01:52:11 -0600 Subject: [PATCH 27/27] fix: ui issues + cleanup for release cantidate --- app/tabs/hosts/navigation/Host.tsx | 5 +++- .../sessions/file-manager/FileManager.tsx | 22 +++++++++++---- .../sessions/file-manager/utils/fileUtils.ts | 28 +++++++++++++++++-- app/tabs/sessions/terminal/Terminal.tsx | 23 +++++++++++++++ 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 8407bab..8960c49 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -330,7 +330,10 @@ function Host({ host, status, isLast = false }: HostProps) { {}}> diff --git a/app/tabs/sessions/file-manager/FileManager.tsx b/app/tabs/sessions/file-manager/FileManager.tsx index 9bb50be..944f3fa 100644 --- a/app/tabs/sessions/file-manager/FileManager.tsx +++ b/app/tabs/sessions/file-manager/FileManager.tsx @@ -567,9 +567,14 @@ export const FileManager = forwardRef( /> )} - + @@ -580,7 +585,7 @@ export const FileManager = forwardRef( borderWidth: BORDERS.MAJOR, borderColor: BORDER_COLORS.PRIMARY, borderRadius: RADIUS.CARD, - marginBottom: insets.bottom, + marginBottom: isLandscape ? 0 : insets.bottom, }} > @@ -640,9 +645,14 @@ export const FileManager = forwardRef( - + @@ -653,7 +663,7 @@ export const FileManager = forwardRef( borderWidth: BORDERS.MAJOR, borderColor: BORDER_COLORS.PRIMARY, borderRadius: RADIUS.CARD, - marginBottom: insets.bottom, + marginBottom: isLandscape ? 0 : insets.bottom, }} > diff --git a/app/tabs/sessions/file-manager/utils/fileUtils.ts b/app/tabs/sessions/file-manager/utils/fileUtils.ts index 814d9bc..1d90051 100644 --- a/app/tabs/sessions/file-manager/utils/fileUtils.ts +++ b/app/tabs/sessions/file-manager/utils/fileUtils.ts @@ -109,8 +109,32 @@ export function formatDate(dateString: string | undefined): string { if (!dateString) return ""; try { - const date = new Date(dateString); const now = new Date(); + let date: Date; + + const parts = dateString.trim().split(/\s+/); + if (parts.length === 3) { + const [month, day, yearOrTime] = parts; + + if (yearOrTime.includes(":")) { + const dateStr = `${month} ${day}, ${now.getFullYear()} ${yearOrTime}:00`; + date = new Date(dateStr); + + if (date > now) { + const lastYearStr = `${month} ${day}, ${now.getFullYear() - 1} ${yearOrTime}:00`; + date = new Date(lastYearStr); + } + } else { + date = new Date(`${month} ${day}, ${yearOrTime}`); + } + } else { + date = new Date(dateString); + } + + if (isNaN(date.getTime())) { + return dateString; + } + const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); @@ -127,7 +151,7 @@ export function formatDate(dateString: string | undefined): string { year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, }); } catch { - return ""; + return dateString; } } diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index ec5c675..949a6b0 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -286,10 +286,17 @@ const TerminalComponent = forwardRef( input, textarea, [contenteditable], .xterm-helper-textarea { position: absolute !important; left: -9999px !important; + top: -9999px !important; width: 1px !important; height: 1px !important; opacity: 0 !important; pointer-events: none !important; + color: transparent !important; + background: transparent !important; + border: none !important; + outline: none !important; + caret-color: transparent !important; + -webkit-text-fill-color: transparent !important; } @@ -355,6 +362,20 @@ const TerminalComponent = forwardRef( fitAddon.fit(); + // Disable autocomplete and suggestions on all input elements + setTimeout(() => { + const inputs = document.querySelectorAll('input, textarea, .xterm-helper-textarea'); + inputs.forEach(input => { + input.setAttribute('autocomplete', 'off'); + input.setAttribute('autocorrect', 'off'); + input.setAttribute('autocapitalize', 'off'); + input.setAttribute('spellcheck', 'false'); + input.style.color = 'transparent'; + input.style.caretColor = 'transparent'; + input.style.webkitTextFillColor = 'transparent'; + }); + }, 100); + const hostConfig = ${JSON.stringify(hostConfig)}; const wsUrl = '${wsUrl}'; @@ -1293,6 +1314,8 @@ const TerminalComponent = forwardRef( showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} nestedScrollEnabled={false} + textZoom={100} + setSupportMultipleWindows={false} />