From 1334cb92d0fd2e3bd86321f3e68992eb62e43478 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Fri, 29 May 2026 12:12:33 +0800 Subject: [PATCH 01/35] fix: register physical Tab key on iPadOS/iOS hardware keyboards (#14) The native module only registered Shift+Tab but not bare Tab, so iOS intercepted it for UI focus navigation. Register an unmodified Tab UIKeyCommand with wantsPriorityOverSystemBehavior and handle both Tab and Shift+Tab in the same JS branch. Fixes Termix-SSH/Support#557 --- app/tabs/sessions/Sessions.tsx | 4 ++-- modules/hardware-keyboard/ios/HardwareKeyboardModule.swift | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index e679592..6b3a174 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -395,8 +395,8 @@ export default function Sessions() { : null; if (!activeRef?.current) return; - if (event.shift && event.input === "\t") { - activeRef.current.sendInput("\x1b[Z"); + if (event.input === "\t") { + activeRef.current.sendInput(event.shift ? "\x1b[Z" : "\t"); return; } diff --git a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift index 89c664c..31df194 100644 --- a/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift +++ b/modules/hardware-keyboard/ios/HardwareKeyboardModule.swift @@ -73,6 +73,11 @@ extension UIViewController { rightCmd.wantsPriorityOverSystemBehavior = true commands.append(rightCmd) + // Tab (unmodified) + let tab = UIKeyCommand(input: "\t", modifierFlags: [], action: #selector(hk_handleTab)) + tab.wantsPriorityOverSystemBehavior = true + commands.append(tab) + // Escape let esc = UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(hk_handleEscape)) esc.wantsPriorityOverSystemBehavior = true @@ -92,6 +97,7 @@ extension UIViewController { return commands } + @objc func hk_handleTab() { HardwareKeyboardModule.emitToAll("\t", shift: false) } @objc func hk_handleShiftTab() { HardwareKeyboardModule.emitToAll("\t", shift: true) } @objc func hk_handleArrowUp() { HardwareKeyboardModule.emitToAll("ArrowUp", shift: false) } @objc func hk_handleArrowDown() { HardwareKeyboardModule.emitToAll("ArrowDown", shift: false) } From c6caa1c2fed4c79345f7806f869d877e9aab7cf4 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Fri, 29 May 2026 12:12:35 +0800 Subject: [PATCH 02/35] fix: pass jumpHosts, forceKeyboardInteractive, and overrideCredentialUsername to terminal connection (#15) These fields were defined on SSHHost but omitted from the terminal hostConfig passthrough. Without jumpHosts the backend cannot route through jump servers; without forceKeyboardInteractive hosts that require keyboard-interactive auth fail silently; without overrideCredentialUsername shared credentials use the wrong username. Fixes Termix-SSH/Support#422 Fixes Termix-SSH/Support#638 --- app/tabs/sessions/Sessions.tsx | 5 +++++ app/tabs/sessions/terminal/NativeWebSocketManager.ts | 3 +++ app/tabs/sessions/terminal/Terminal.tsx | 3 +++ 3 files changed, 11 insertions(+) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 6b3a174..2936875 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -571,6 +571,11 @@ export default function Sessions() { credentialId: session.host.credentialId ? parseInt(session.host.credentialId.toString()) : undefined, + jumpHosts: session.host.jumpHosts, + forceKeyboardInteractive: + session.host.forceKeyboardInteractive, + overrideCredentialUsername: + session.host.overrideCredentialUsername, terminalConfig: session.host.terminalConfig, }} isVisible={session.id === activeSessionId} diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts index 48b1563..bb29aee 100644 --- a/app/tabs/sessions/terminal/NativeWebSocketManager.ts +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -12,6 +12,9 @@ export interface TerminalHostConfig { keyPassword?: string; keyType?: string; credentialId?: number; + jumpHosts?: { hostId: number }[]; + forceKeyboardInteractive?: boolean; + overrideCredentialUsername?: boolean; } export type WsState = diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 6444752..830301b 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -45,6 +45,9 @@ interface TerminalProps { keyPassword?: string; keyType?: string; credentialId?: number; + jumpHosts?: { hostId: number }[]; + forceKeyboardInteractive?: boolean; + overrideCredentialUsername?: boolean; terminalConfig?: Partial; }; isVisible: boolean; From 06950990d6456ca7213fc05bacfbc960a154ae06 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Fri, 29 May 2026 12:12:43 +0800 Subject: [PATCH 03/35] fix: improve mobile terminal scroll recovery (#17) --- app/tabs/sessions/terminal/Terminal.tsx | 111 +++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 830301b..84d3956 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -12,8 +12,10 @@ import { ActivityIndicator, Dimensions, AccessibilityInfo, + TouchableOpacity, } from "react-native"; import { WebView } from "react-native-webview"; +import { ChevronDown } from "lucide-react-native"; import { logActivity, getSnippets } from "../../../main-axios"; import { showToast } from "../../../utils/toast"; import { useTerminalCustomization } from "../../../contexts/TerminalCustomizationContext"; @@ -113,6 +115,8 @@ const TerminalComponent = forwardRef( "no_keyboard" | "auth_failed" | "timeout" >("auth_failed"); const [isSelecting, setIsSelecting] = useState(false); + const [showScrollToBottomButton, setShowScrollToBottomButton] = + useState(false); const [hostKeyVerification, setHostKeyVerification] = useState<{ scenario: "new" | "changed"; data: HostKeyData; @@ -236,6 +240,7 @@ const TerminalComponent = forwardRef( Terminal + + + + +
+ + +`; + }, [webSocketUrl]); + + const reconnect = useCallback(() => { + setWebSocketUrl(null); + setWebViewKey((current) => current + 1); + }, []); + + const injectRemoteCommand = useCallback((script: string) => { + webViewRef.current?.injectJavaScript(`${script}; true;`); + }, []); + + const sendKeysym = useCallback( + (keysym: number) => { + injectRemoteCommand( + `window.termixRemote && window.termixRemote.sendKeysym(${keysym})`, + ); + }, + [injectRemoteCommand], + ); + + const sendKeysyms = useCallback( + (keysyms: number[]) => { + injectRemoteCommand( + `window.termixRemote && window.termixRemote.sendKeysyms(${JSON.stringify(keysyms)})`, + ); + }, + [injectRemoteCommand], + ); + + const sendText = useCallback( + (text: string) => { + injectRemoteCommand( + `window.termixRemote && window.termixRemote.sendText(${JSON.stringify(text)})`, + ); + }, + [injectRemoteCommand], + ); + + const handleInputChange = useCallback( + (text: string) => { + if (text) { + sendText(text); + } + setInputValue(""); + }, + [sendText], + ); + + const protocol = (host.connectionType || "rdp").toUpperCase(); + const canSendInput = connectionState === "connected"; + + useEffect(() => { + if (!canSendInput) return; + + injectRemoteCommand( + `window.termixRemote && window.termixRemote.resize(${Math.round(width)}, ${Math.round(height)})`, + ); + }, [canSendInput, height, injectRemoteCommand, width]); + + return ( + + {htmlContent ? ( + { + setConnectionState("failed"); + setErrorMessage(event.nativeEvent.description); + }} + onHttpError={(event) => { + setConnectionState("failed"); + setErrorMessage( + `WebView HTTP error: ${event.nativeEvent.statusCode}`, + ); + }} + scrollEnabled={false} + overScrollMode="never" + bounces={false} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + setSupportMultipleWindows={false} + /> + ) : null} + + {(connectionState === "connecting" || connectionState === "idle") && ( + + + Connecting {protocol} + {title} + + )} + + {(connectionState === "failed" || connectionState === "disconnected") && ( + + + {connectionState === "failed" + ? "Connection Failed" + : "Disconnected"} + + {errorMessage ? ( + {errorMessage} + ) : null} + + + Reconnect + + + )} + + {canSendInput && ( + + { + if (nativeEvent.key === "Backspace") { + sendKeysym(0xff08); + } else if (nativeEvent.key === "Enter") { + sendKeysym(0xff0d); + } + }} + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + style={styles.hiddenInput} + /> + + } + onPress={() => inputRef.current?.focus()} + /> + sendKeysym(0xff1b)} /> + sendKeysym(0xff09)} /> + sendKeysym(0xff0d)} /> + sendKeysym(0xff08)} /> + sendKeysym(0xff51)} /> + sendKeysym(0xff52)} /> + sendKeysym(0xff54)} /> + sendKeysym(0xff53)} /> + sendKeysyms([0xffe3, 0xffe9, 0xffff])} + /> + + + )} + + ); +} + +function RemoteKeyButton({ + label, + icon, + onPress, +}: { + label: string; + icon?: React.ReactNode; + onPress: () => void; +}) { + return ( + + {icon} + {label} + + ); +} + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#050608", + }, + webView: { + flex: 1, + width: "100%", + height: "100%", + backgroundColor: "#050608", + }, + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + backgroundColor: "#050608", + }, + overlayTitle: { + marginTop: 12, + color: "#ffffff", + fontSize: 16, + fontWeight: "600", + textAlign: "center", + }, + overlayText: { + marginTop: 8, + color: "#9CA3AF", + fontSize: 13, + lineHeight: 18, + textAlign: "center", + }, + retryButton: { + marginTop: 18, + minHeight: 40, + paddingHorizontal: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: "#16A34A", + backgroundColor: "#22C55E", + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + retryText: { + color: "#ffffff", + fontSize: 14, + fontWeight: "600", + }, + toolbar: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + minHeight: 48, + paddingHorizontal: 8, + paddingVertical: 6, + backgroundColor: "#111827", + borderTopWidth: 1, + borderTopColor: "#303032", + }, + toolbarContent: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + hiddenInput: { + position: "absolute", + width: 1, + height: 1, + opacity: 0, + color: "transparent", + }, + keyButton: { + minWidth: 44, + height: 36, + paddingHorizontal: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: "#303032", + backgroundColor: "#1F2937", + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: 4, + }, + keyButtonText: { + color: "#ffffff", + fontSize: 12, + fontWeight: "600", + }, +}); diff --git a/types/index.ts b/types/index.ts index 97c094e..c2b312e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -13,7 +13,7 @@ export interface QuickAction { export interface SSHHost { id: number; - connectionType?: string; + connectionType?: "ssh" | "rdp" | "vnc" | "telnet" | string; name: string; ip: string; port: number; From e47e1bb6d7b131a6d4d3c7e21f1c61152ff92873 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Fri, 29 May 2026 12:12:56 +0800 Subject: [PATCH 05/35] feat: add mobile terminal font selection (#19) --- app/contexts/TerminalCustomizationContext.tsx | 14 +- app/tabs/sessions/terminal/Terminal.tsx | 2 +- app/tabs/settings/TerminalCustomization.tsx | 146 +++++++++++++----- 3 files changed, 118 insertions(+), 44 deletions(-) diff --git a/app/contexts/TerminalCustomizationContext.tsx b/app/contexts/TerminalCustomizationContext.tsx index 2b2c17e..1e65280 100644 --- a/app/contexts/TerminalCustomizationContext.tsx +++ b/app/contexts/TerminalCustomizationContext.tsx @@ -22,6 +22,7 @@ interface TerminalCustomizationContextType { resetConfig: () => Promise; updateFontSize: (fontSize: number) => Promise; + updateFontFamily: (fontFamily: string) => Promise; resetToDefault: () => Promise; } @@ -42,7 +43,10 @@ export const TerminalCustomizationProvider: React.FC<{ const stored = await AsyncStorage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored) as Partial; - setConfig(parsed); + setConfig({ + ...getDefaultConfig(), + ...parsed, + }); } } catch (error) { console.error("Failed to load terminal configuration:", error); @@ -85,6 +89,13 @@ export const TerminalCustomizationProvider: React.FC<{ [updateConfig], ); + const updateFontFamily = useCallback( + async (fontFamily: string) => { + await updateConfig({ fontFamily }); + }, + [updateConfig], + ); + const resetToDefault = useCallback(async () => { await resetConfig(); }, [resetConfig]); @@ -95,6 +106,7 @@ export const TerminalCustomizationProvider: React.FC<{ updateConfig, resetConfig, updateFontSize, + updateFontFamily, resetToDefault, }; diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 84d3956..d00b255 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -281,7 +281,7 @@ const TerminalComponent = forwardRef( } .xterm .xterm-screen { - font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important; + font-family: ${fontFamily} !important; font-variant-ligatures: contextual; } diff --git a/app/tabs/settings/TerminalCustomization.tsx b/app/tabs/settings/TerminalCustomization.tsx index 1170107..1427682 100644 --- a/app/tabs/settings/TerminalCustomization.tsx +++ b/app/tabs/settings/TerminalCustomization.tsx @@ -14,6 +14,7 @@ import { useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTerminalCustomization } from "@/app/contexts/TerminalCustomizationContext"; import { showToast } from "@/app/utils/toast"; +import { TERMINAL_FONTS } from "@/constants/terminal-themes"; const FONT_SIZE_OPTIONS = [ { label: "Extra Small", value: 12 }, @@ -27,7 +28,8 @@ const FONT_SIZE_OPTIONS = [ export default function TerminalCustomization() { const router = useRouter(); const insets = useSafeAreaInsets(); - const { config, updateFontSize, resetToDefault } = useTerminalCustomization(); + const { config, updateFontFamily, updateFontSize, resetToDefault } = + useTerminalCustomization(); const [showResetConfirm, setShowResetConfirm] = useState(false); const [customFontSize, setCustomFontSize] = useState(""); const [showCustomInput, setShowCustomInput] = useState(false); @@ -40,17 +42,26 @@ export default function TerminalCustomization() { try { await updateFontSize(fontSize); showToast.success(`Font size updated to ${fontSize}px`); - } catch (error) { + } catch { showToast.error("Failed to update font size"); } }; + const handleFontFamilyChange = async (fontFamily: string, label: string) => { + try { + await updateFontFamily(fontFamily); + showToast.success(`Font updated to ${label}`); + } catch { + showToast.error("Failed to update font"); + } + }; + const handleReset = async () => { try { await resetToDefault(); showToast.success("Terminal settings reset to default"); setShowResetConfirm(false); - } catch (error) { + } catch { showToast.error("Failed to reset settings"); } }; @@ -66,7 +77,7 @@ export default function TerminalCustomization() { showToast.success(`Font size updated to ${fontSize}px`); setShowCustomInput(false); setCustomFontSize(""); - } catch (error) { + } catch { showToast.error("Failed to update font size"); } }; @@ -74,16 +85,16 @@ export default function TerminalCustomization() { return ( router.back()}> - + ← Back - + Terminal Customization @@ -94,18 +105,69 @@ export default function TerminalCustomization() { className="flex-1 px-4 py-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) }} > - + Terminal Settings - + Customize terminal appearance and behavior - + Font + + Select the terminal font family. Nerd Font support depends on the + selected font being available to the WebView. + + + {TERMINAL_FONTS.map((option) => { + const isActive = config.fontFamily === option.value; + return ( + + handleFontFamilyChange(option.value, option.label) + } + className={`rounded-lg border p-4 ${ + isActive + ? "border-green-500 bg-green-900/20" + : "border-[#303032] bg-[#1a1a1a]" + }`} + > + + + + {option.label} + + + Aa Bb Cc 123  + + + {isActive && ( + + + ACTIVE + + + )} + + + ); + })} + + + + + Font Size - + Base font size for terminal text. The actual size will be adjusted based on your screen width. This number will override the font size you configured on a host in the Termix Web UI. @@ -115,10 +177,10 @@ export default function TerminalCustomization() { handleFontSizeChange(option.value)} - className={`p-4 rounded-lg border ${ + className={`rounded-lg border p-4 ${ config.fontSize === option.value - ? "bg-green-900/20 border-green-500" - : "bg-[#1a1a1a] border-[#303032]" + ? "border-green-500 bg-green-900/20" + : "border-[#303032] bg-[#1a1a1a]" }`} > @@ -132,13 +194,13 @@ export default function TerminalCustomization() { > {option.label} - + {option.value}px base size {config.fontSize === option.value && ( - - + + ACTIVE @@ -149,10 +211,10 @@ export default function TerminalCustomization() { setShowCustomInput(true)} - className={`p-4 rounded-lg border ${ + className={`rounded-lg border p-4 ${ isCustomFontSize - ? "bg-green-900/20 border-green-500" - : "bg-[#1a1a1a] border-[#303032]" + ? "border-green-500 bg-green-900/20" + : "border-[#303032] bg-[#1a1a1a]" }`} > @@ -164,15 +226,15 @@ export default function TerminalCustomization() { > Custom - + {isCustomFontSize ? `${config.fontSize}px base size` : "Enter any custom size"} {isCustomFontSize && ( - - + + ACTIVE @@ -184,9 +246,9 @@ export default function TerminalCustomization() { setShowResetConfirm(true)} - className="bg-red-900/20 border border-red-700 rounded-lg p-3" + className="rounded-lg border border-red-700 bg-red-900/20 p-3" > - + Reset to Default @@ -207,17 +269,17 @@ export default function TerminalCustomization() { className="flex-1" > { setShowCustomInput(false); setCustomFontSize(""); }} > - - + + Custom Font Size - + Enter your preferred font size for the terminal. - + { setShowCustomInput(false); setCustomFontSize(""); }} - className="flex-1 bg-[#27272a] border border-[#3f3f46] rounded-lg p-3" + className="flex-1 rounded-lg border border-[#3f3f46] bg-[#27272a] p-3" > - + Cancel - + Apply @@ -272,30 +334,30 @@ export default function TerminalCustomization() { supportedOrientations={["portrait", "landscape"]} > setShowResetConfirm(false)} > - - + + Confirm Reset - + This will reset all terminal customizations to default settings. setShowResetConfirm(false)} - className="flex-1 bg-[#27272a] border border-[#3f3f46] rounded-lg p-3" + className="flex-1 rounded-lg border border-[#3f3f46] bg-[#27272a] p-3" > - + Cancel - + Reset From de9372f98b31d40c7e95834fbbd967f34d060030 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Fri, 29 May 2026 12:13:06 +0800 Subject: [PATCH 06/35] feat: add mobile shift tab hotkey (#20) --- app/tabs/sessions/Sessions.tsx | 26 ++++---- .../terminal/keyboard/CustomKeyboard.tsx | 31 ++++++---- .../terminal/keyboard/KeyDefinitions.ts | 33 +++++++++- .../terminal/keyboard/KeyboardBar.tsx | 60 +++++++++++++------ 4 files changed, 107 insertions(+), 43 deletions(-) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index d4c6c80..bdbdd46 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -43,6 +43,12 @@ import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; import { BACKGROUNDS, BORDER_COLORS } from "@/app/constants/designTokens"; import { addKeyCommandListener } from "@/modules/hardware-keyboard"; +type ActiveModifiers = { + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + export default function Sessions() { const insets = useSafeAreaInsets(); const router = useRouter(); @@ -77,6 +83,7 @@ export default function Sessions() { const [activeModifiers, setActiveModifiers] = useState({ ctrl: false, alt: false, + shift: false, }); const [screenDimensions, setScreenDimensions] = useState( Dimensions.get("window"), @@ -513,12 +520,9 @@ export default function Sessions() { } }; - const handleModifierChange = useCallback( - (modifiers: { ctrl: boolean; alt: boolean }) => { - setActiveModifiers(modifiers); - }, - [], - ); + const handleModifierChange = useCallback((modifiers: ActiveModifiers) => { + setActiveModifiers(modifiers); + }, []); const activeTerminalBgColor = activeSession?.type === "terminal" && activeSessionId @@ -960,7 +964,7 @@ export default function Sessions() { finalKey = "\x7f"; break; case "Tab": - finalKey = "\t"; + finalKey = activeModifiers.shift ? "\x1b[Z" : "\t"; break; case "Escape": finalKey = "\x1b"; @@ -1033,10 +1037,12 @@ export default function Sessions() { if (activeModifiers.ctrl) { finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); } else if (activeModifiers.alt) { - finalKey = `\x1b${key}`; + finalKey = `\x1b${activeModifiers.shift ? key.toUpperCase() : key}`; } else { - finalKey = key; - dictationSentRef.current = hiddenInputValue + key; + finalKey = activeModifiers.shift + ? key.toUpperCase() + : key; + dictationSentRef.current = hiddenInputValue + finalKey; } } } diff --git a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx index 701d70a..e3e38f6 100644 --- a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx +++ b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx @@ -1,11 +1,11 @@ -import React from "react"; +import React, { useState } from "react"; import { View, ScrollView, Text } from "react-native"; import * as Clipboard from "expo-clipboard"; 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"; +import { BORDER_COLORS } from "@/app/constants/designTokens"; interface CustomKeyboardProps { terminalRef: React.RefObject; @@ -17,10 +17,11 @@ interface CustomKeyboardProps { export default function CustomKeyboard({ terminalRef, isVisible, - keyboardHeight, + keyboardHeight: _keyboardHeight, isKeyboardIntentionallyHidden = false, }: CustomKeyboardProps) { const { config } = useKeyboardCustomization(); + const [shiftPressed, setShiftPressed] = useState(false); if (!isVisible) return null; @@ -50,7 +51,13 @@ export default function CustomKeyboard({ case "tab": case "complete": case "comp": - sendKey("\t"); + sendKey(shiftPressed ? "\x1b[Z" : "\t"); + break; + case "shiftTab": + sendKey("\x1b[Z"); + break; + case "shift": + setShiftPressed((current) => !current); break; case "arrowUp": case "history": @@ -98,7 +105,7 @@ export default function CustomKeyboard({ if (clipboardContent) { sendKey(clipboardContent); } - } catch (error) {} + } catch {} }; const { rows } = config.fullKeyboard; @@ -128,8 +135,6 @@ export default function CustomKeyboard({ return baseStyle; }; - const safeKeyboardHeight = Math.max(200, Math.min(keyboardHeight, 500)); - return ( {row.label && ( - + {row.label} )} handleKeyPress(key)} style={getKeyStyle(key)} + isModifier={key.isModifier || key.id === "shift"} + isActive={key.id === "shift" && shiftPressed} keySize={config.settings.keySize} hapticFeedback={config.settings.hapticFeedback} /> @@ -169,7 +176,7 @@ export default function CustomKeyboard({ {rowIndex < visibleRows.length - 1 && ( - + + Customize in Settings diff --git a/app/tabs/sessions/terminal/keyboard/KeyDefinitions.ts b/app/tabs/sessions/terminal/keyboard/KeyDefinitions.ts index 38a5789..5fffebb 100644 --- a/app/tabs/sessions/terminal/keyboard/KeyDefinitions.ts +++ b/app/tabs/sessions/terminal/keyboard/KeyDefinitions.ts @@ -1,6 +1,5 @@ import { KeyConfig, - KeyboardRow, PresetDefinition, TopBarConfig, FullKeyboardConfig, @@ -37,6 +36,14 @@ export const ALL_KEYS: Record = { isModifier: true, description: "Alt modifier (toggle)", }, + shift: { + id: "shift", + label: "Shift", + value: "", + category: "modifier", + isModifier: true, + description: "Shift modifier (toggle)", + }, arrowUp: { id: "arrowUp", @@ -465,6 +472,13 @@ export const ALL_KEYS: Record = { category: "shortcut", description: "Alt+D (delete word)", }, + shiftTab: { + id: "shiftTab", + label: "Shift+Tab", + value: "\x1b[Z", + category: "shortcut", + description: "Shift+Tab (reverse tab)", + }, paste: { id: "paste", @@ -496,6 +510,7 @@ const defaultTopBar: TopBarConfig = { ALL_KEYS.tab, ALL_KEYS.ctrl, ALL_KEYS.alt, + ALL_KEYS.shift, ALL_KEYS.arrowUp, ALL_KEYS.arrowDown, ALL_KEYS.arrowLeft, @@ -654,6 +669,7 @@ const defaultFullKeyboard: FullKeyboardConfig = { ALL_KEYS.ctrlR, ALL_KEYS.ctrlY, ALL_KEYS.altF, + ALL_KEYS.shiftTab, ], }, ], @@ -666,6 +682,7 @@ const minimalTopBar: TopBarConfig = { ALL_KEYS.tab, ALL_KEYS.ctrl, ALL_KEYS.alt, + ALL_KEYS.shift, ALL_KEYS.arrowUp, ALL_KEYS.arrowDown, ALL_KEYS.arrowLeft, @@ -718,7 +735,13 @@ const minimalFullKeyboard: FullKeyboardConfig = { category: "shortcut", label: "Shortcuts", visible: true, - keys: [ALL_KEYS.ctrlC, ALL_KEYS.ctrlD, ALL_KEYS.ctrlL, ALL_KEYS.ctrlZ], + keys: [ + ALL_KEYS.ctrlC, + ALL_KEYS.ctrlD, + ALL_KEYS.ctrlL, + ALL_KEYS.ctrlZ, + ALL_KEYS.shiftTab, + ], }, ], }; @@ -729,6 +752,7 @@ const developerTopBar: TopBarConfig = { ALL_KEYS.tab, ALL_KEYS.ctrl, ALL_KEYS.alt, + ALL_KEYS.shift, ALL_KEYS.arrowUp, ALL_KEYS.arrowDown, ALL_KEYS.arrowLeft, @@ -841,6 +865,7 @@ const developerFullKeyboard: FullKeyboardConfig = { ALL_KEYS.ctrlU, ALL_KEYS.ctrlR, ALL_KEYS.ctrlW, + ALL_KEYS.shiftTab, ], }, ], @@ -853,6 +878,7 @@ const sysadminTopBar: TopBarConfig = { ALL_KEYS.tab, ALL_KEYS.ctrl, ALL_KEYS.alt, + ALL_KEYS.shift, ALL_KEYS.arrowUp, ALL_KEYS.arrowDown, ALL_KEYS.arrowLeft, @@ -940,6 +966,7 @@ const sysadminFullKeyboard: FullKeyboardConfig = { ALL_KEYS.ctrlL, ALL_KEYS.ctrlR, ALL_KEYS.ctrlU, + ALL_KEYS.shiftTab, ], }, ], @@ -952,6 +979,7 @@ const compactTopBar: TopBarConfig = { ALL_KEYS.tab, ALL_KEYS.ctrl, ALL_KEYS.alt, + ALL_KEYS.shift, ALL_KEYS.arrowUp, ALL_KEYS.arrowDown, ALL_KEYS.arrowLeft, @@ -1081,6 +1109,7 @@ const compactFullKeyboard: FullKeyboardConfig = { ALL_KEYS.ctrlU, ALL_KEYS.ctrlW, ALL_KEYS.ctrlR, + ALL_KEYS.shiftTab, ], }, ], diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx index 9a44ae1..a144e82 100644 --- a/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx +++ b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx @@ -1,22 +1,21 @@ import React, { useState, useEffect } from "react"; -import { View, ScrollView, Text, Platform } from "react-native"; +import { View, ScrollView } from "react-native"; import * as Clipboard from "expo-clipboard"; 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, - BACKGROUNDS, -} from "@/app/constants/designTokens"; +import { BORDER_COLORS, BACKGROUNDS } from "@/app/constants/designTokens"; interface KeyboardBarProps { terminalRef: React.RefObject; isVisible: boolean; - onModifierChange?: (modifiers: { ctrl: boolean; alt: boolean }) => void; + onModifierChange?: (modifiers: { + ctrl: boolean; + alt: boolean; + shift: boolean; + }) => void; isKeyboardIntentionallyHidden?: boolean; } @@ -27,12 +26,10 @@ export default function KeyboardBar({ isKeyboardIntentionallyHidden = false, }: KeyboardBarProps) { const { config } = useKeyboardCustomization(); - const { keyboardHeight, isKeyboardVisible } = useKeyboard(); const { isLandscape } = useOrientation(); const [ctrlPressed, setCtrlPressed] = useState(false); const [altPressed, setAltPressed] = useState(false); - - if (!isVisible) return null; + const [shiftPressed, setShiftPressed] = useState(false); const sendKey = (key: string) => { terminalRef.current?.sendInput(key); @@ -48,7 +45,10 @@ export default function KeyboardBar({ case "tab": case "complete": case "comp": - sendKey("\t"); + sendKey(shiftPressed ? "\x1b[Z" : "\t"); + break; + case "shiftTab": + sendKey("\x1b[Z"); break; case "arrowUp": case "history": @@ -78,10 +78,10 @@ export default function KeyboardBar({ if (clipboardContent) { sendKey(clipboardContent); } - } catch (error) {} + } catch {} }; - const toggleModifier = (modifier: "ctrl" | "alt") => { + const toggleModifier = (modifier: "ctrl" | "alt" | "shift") => { switch (modifier) { case "ctrl": setCtrlPressed(!ctrlPressed); @@ -89,20 +89,33 @@ export default function KeyboardBar({ case "alt": setAltPressed(!altPressed); break; + case "shift": + setShiftPressed(!shiftPressed); + break; } }; useEffect(() => { if (onModifierChange) { - onModifierChange({ ctrl: ctrlPressed, alt: altPressed }); + onModifierChange({ + ctrl: ctrlPressed, + alt: altPressed, + shift: shiftPressed, + }); } - }, [ctrlPressed, altPressed]); + }, [ctrlPressed, altPressed, shiftPressed, onModifierChange]); + + if (!isVisible) return null; const renderKey = (keyConfig: KeyConfig, index: number) => { const isModifier = - keyConfig.isModifier || keyConfig.id === "ctrl" || keyConfig.id === "alt"; + keyConfig.isModifier || + keyConfig.id === "ctrl" || + keyConfig.id === "alt" || + keyConfig.id === "shift"; const isCtrl = keyConfig.id === "ctrl"; const isAlt = keyConfig.id === "alt"; + const isShift = keyConfig.id === "shift"; return ( @@ -150,7 +172,7 @@ export default function KeyboardBar({ <> {pinnedKeys.map((key, index) => renderKey(key, index))} From 5598ad454fa043c3aae9a8aa38097aae0bfe07bb Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 29 May 2026 01:42:07 -0500 Subject: [PATCH 07/35] feat: initial UI redesign --- app.json | 12 +- app/(tabs)/_layout.tsx | 64 +- app/AppContext.tsx | 2 - app/_layout.tsx | 134 +++-- app/authentication/LoginForm.tsx | 61 +- app/authentication/ServerForm.tsx | 182 +++--- app/authentication/UpdateRequired.tsx | 113 ++-- app/components/CustomTabBar.tsx | 78 +++ app/components/LockScreen.tsx | 116 ++++ app/components/Screen.tsx | 47 ++ app/components/ui/Accordion.tsx | 59 ++ app/components/ui/Badge.tsx | 41 ++ app/components/ui/BottomSheet.tsx | 86 +++ app/components/ui/Button.tsx | 95 +++ app/components/ui/Card.tsx | 32 + app/components/ui/Dialog.tsx | 77 +++ app/components/ui/Input.tsx | 36 ++ app/components/ui/SegmentedControl.tsx | 40 ++ app/components/ui/SettingRow.tsx | 50 ++ app/components/ui/Switch.tsx | 59 ++ app/components/ui/Text.tsx | 36 ++ app/components/ui/index.ts | 14 + app/constants/designTokens.ts | 63 +- app/constants/fonts.ts | 21 + app/constants/theme.ts | 461 ++++++++++++++ app/contexts/AppLockContext.tsx | 138 +++++ app/contexts/KeyboardCustomizationContext.tsx | 1 - app/contexts/TerminalSessionsContext.tsx | 148 ++++- app/contexts/ThemeContext.tsx | 172 ++++++ app/main-axios.ts | 181 ++++++ .../dialogs/HostKeyVerificationDialog.tsx | 2 +- app/tabs/dialogs/SSHAuthDialog.tsx | 1 - app/tabs/dialogs/TOTPDialog.tsx | 2 +- app/tabs/hosts/HostActionSheet.tsx | 166 ++++++ app/tabs/hosts/HostForm.tsx | 484 +++++++++++++++ app/tabs/hosts/Hosts.tsx | 418 ++++++++----- app/tabs/hosts/QuickConnect.tsx | 162 +++++ app/tabs/hosts/navigation/Folder.tsx | 125 ++-- app/tabs/hosts/navigation/Host.tsx | 563 +++--------------- app/tabs/sessions/ConnectionsPanel.tsx | 351 +++++++++++ app/tabs/sessions/Sessions.tsx | 102 +--- app/tabs/sessions/docker/Docker.tsx | 218 +++++++ .../sessions/file-manager/ContextMenu.tsx | 46 +- app/tabs/sessions/file-manager/FileItem.tsx | 14 +- app/tabs/sessions/file-manager/FileList.tsx | 9 +- .../sessions/file-manager/FileManager.tsx | 28 +- .../file-manager/FileManagerHeader.tsx | 1 - app/tabs/sessions/file-manager/FileViewer.tsx | 19 +- app/tabs/sessions/navigation/TabBar.tsx | 2 +- .../sessions/server-stats/ServerStats.tsx | 24 +- .../terminal/NativeWebSocketManager.ts | 29 +- app/tabs/sessions/terminal/Terminal.tsx | 16 +- .../terminal/keyboard/BottomToolbar.tsx | 7 +- .../terminal/keyboard/CustomKeyboard.tsx | 4 +- .../terminal/keyboard/KeyboardKey.tsx | 2 +- .../terminal/keyboard/SnippetsBar.tsx | 34 +- app/tabs/sessions/tunnel/TunnelCard.tsx | 2 +- app/tabs/sessions/tunnel/TunnelManager.tsx | 24 +- app/tabs/settings/KeyboardCustomization.tsx | 60 +- app/tabs/settings/Settings.tsx | 473 ++++++++++++--- app/tabs/settings/TerminalCustomization.tsx | 60 +- .../settings/components/DraggableKeyList.tsx | 6 +- .../settings/components/DraggableRowList.tsx | 9 +- app/tabs/settings/components/KeySelector.tsx | 27 +- .../components/UnifiedDraggableList.tsx | 16 +- app/utils/toast.ts | 78 +-- global.css | 88 ++- package-lock.json | 30 + package.json | 3 + tailwind.config.js | 98 ++- types/index.ts | 40 ++ 71 files changed, 4968 insertions(+), 1494 deletions(-) create mode 100644 app/components/CustomTabBar.tsx create mode 100644 app/components/LockScreen.tsx create mode 100644 app/components/Screen.tsx create mode 100644 app/components/ui/Accordion.tsx create mode 100644 app/components/ui/Badge.tsx create mode 100644 app/components/ui/BottomSheet.tsx create mode 100644 app/components/ui/Button.tsx create mode 100644 app/components/ui/Card.tsx create mode 100644 app/components/ui/Dialog.tsx create mode 100644 app/components/ui/Input.tsx create mode 100644 app/components/ui/SegmentedControl.tsx create mode 100644 app/components/ui/SettingRow.tsx create mode 100644 app/components/ui/Switch.tsx create mode 100644 app/components/ui/Text.tsx create mode 100644 app/components/ui/index.ts create mode 100644 app/constants/fonts.ts create mode 100644 app/constants/theme.ts create mode 100644 app/contexts/AppLockContext.tsx create mode 100644 app/contexts/ThemeContext.tsx create mode 100644 app/tabs/hosts/HostActionSheet.tsx create mode 100644 app/tabs/hosts/HostForm.tsx create mode 100644 app/tabs/hosts/QuickConnect.tsx create mode 100644 app/tabs/sessions/ConnectionsPanel.tsx create mode 100644 app/tabs/sessions/docker/Docker.tsx diff --git a/app.json b/app.json index 5326bbf..71a2992 100644 --- a/app.json +++ b/app.json @@ -20,7 +20,8 @@ }, "infoPlist": { "CFBundleAllowMixedLocalizations": true, - "ITSAppUsesNonExemptEncryption": false + "ITSAppUsesNonExemptEncryption": false, + "NSFaceIDUsageDescription": "Use Face ID to unlock Termix." } }, "android": { @@ -37,6 +38,12 @@ }, "plugins": [ "expo-router", + [ + "expo-local-authentication", + { + "faceIDPermission": "Use Face ID to unlock Termix." + } + ], [ "expo-splash-screen", { @@ -64,7 +71,8 @@ ], "./plugins/withIOSNetworkSecurity.js", "./plugins/withNetworkSecurityConfig.js", - "expo-dev-client" + "expo-dev-client", + "expo-secure-store" ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 0f607b2..b9f11a6 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,70 +1,22 @@ 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"; +import { CustomTabBar } from "@/app/components/CustomTabBar"; 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); + // Hide the main tab bar when viewing a full-screen session. + const hideTabBar = pathname === "/sessions" && sessions.length > 0; return ( null : (props) => } + screenOptions={{ headerShown: false }} > - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> + + + ); } diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 1fc7a5d..a4beb5a 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -11,10 +11,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { getVersionInfo, initializeServerConfig, - isAuthenticated as checkAuthStatus, getLatestGitHubRelease, setAuthStateCallback, - clearServerConfig, } from "./main-axios"; import Constants from "expo-constants"; diff --git a/app/_layout.tsx b/app/_layout.tsx index 1dae430..a0695dc 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,12 +4,18 @@ import { TerminalSessionsProvider } from "./contexts/TerminalSessionsContext"; import { TerminalCustomizationProvider } from "./contexts/TerminalCustomizationContext"; import { KeyboardProvider } from "./contexts/KeyboardContext"; import { KeyboardCustomizationProvider } from "./contexts/KeyboardCustomizationContext"; +import { ThemeProvider, useTheme, useThemeColor } from "./contexts/ThemeContext"; +import { AppLockProvider, useAppLock } from "./contexts/AppLockContext"; +import { LockScreen } from "@/app/components/LockScreen"; 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 { StatusBar } from "expo-status-bar"; import { Toaster } from "sonner-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { useFonts } from "expo-font"; +import { FONT_MAP, MONO_FONT, MONO_FONT_BOLD } from "./constants/fonts"; import "../global.css"; import UpdateRequired from "@/app/authentication/UpdateRequired"; @@ -24,49 +30,49 @@ function RootLayoutContent() { isLoading, setIsLoading, } = useAppContext(); + const accent = useThemeColor()("accent-brand"); if (isLoading) { return ( - - - Initializing... + + + + Initializing… + { + setIsLoading(false); setShowLoginForm(false); setShowServerManager(true); }} - className="mt-6 px-6 py-3 bg-[#1a1a1a] border border-[#303032] rounded-lg" + className="mt-6 px-6 py-3 bg-card border border-border" > - Cancel + + Cancel + ); } - if (showUpdateScreen) { - return ; - } - - if (showServerManager) { - return ; - } - - if (showLoginForm) { - return ; - } + if (showUpdateScreen) return ; + if (showServerManager) return ; + if (showLoginForm) return ; if (isAuthenticated) { return ( - - + + + ); } @@ -74,35 +80,67 @@ function RootLayoutContent() { return ; } +function AppLockGate() { + const { enabled, locked } = useAppLock(); + if (!enabled || !locked) return null; + return ; +} + +function ThemedToaster() { + const { isDark } = useTheme(); + const card = useThemeColor()("card"); + const border = useThemeColor()("border"); + return ( + + ); +} + +function ThemedStatusBar() { + const { isDark } = useTheme(); + return ; +} + export default function RootLayout() { + const [fontsLoaded] = useFonts(FONT_MAP); + return ( - - - - - - - - - - - - + + + {!fontsLoaded ? ( + + ) : ( + + + + + + + + + + + + + + + )} + ); diff --git a/app/authentication/LoginForm.tsx b/app/authentication/LoginForm.tsx index 3537f3d..587b9da 100644 --- a/app/authentication/LoginForm.tsx +++ b/app/authentication/LoginForm.tsx @@ -1,11 +1,4 @@ -import { - View, - TouchableOpacity, - Text, - Alert, - ActivityIndicator, - Platform, -} from "react-native"; +import { View, TouchableOpacity, Alert, ActivityIndicator, Platform } from "react-native"; import { useAppContext } from "../AppContext"; import { useState, useEffect, useRef } from "react"; import { @@ -16,8 +9,9 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ArrowLeft, RefreshCw } from "lucide-react-native"; import { WebView, WebViewNavigation } from "react-native-webview"; -import { WebViewSource } from "react-native-webview/lib/WebViewTypes"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; export default function LoginForm() { const { @@ -27,11 +21,14 @@ export default function LoginForm() { selectedServer, } = useAppContext(); const insets = useSafeAreaInsets(); + const color = useThemeColor(); + const bg = color("background") ?? "#0c0d0b"; + const accent = color("accent-brand") ?? "#f59145"; const webViewRef = useRef(null); const [url, setUrl] = useState(""); const [canGoBack, setCanGoBack] = useState(false); const [loading, setLoading] = useState(true); - const [source, setSource] = useState({ uri: "" }); + const [source, setSource] = useState<{ uri: string }>({ uri: "" }); const [webViewKey, setWebViewKey] = useState(() => String(Date.now())); const [hasNavigated, setHasNavigated] = useState(false); @@ -383,30 +380,35 @@ export default function LoginForm() { if (!source.uri) { return ( - - - Loading server configuration... + + + + Loading server configuration… + ); } return ( - - + + - - Server + + + Server + - - + + {url.replace(/^https?:\/\//, "")} - - + + @@ -419,16 +421,16 @@ export default function LoginForm() { ? "Termix-Mobile/Android" : "Termix-Mobile/iOS" } - style={{ flex: 1, backgroundColor: "#18181b" }} - containerStyle={{ backgroundColor: "#18181b" }} + style={{ flex: 1, backgroundColor: bg }} + containerStyle={{ backgroundColor: bg }} onNavigationStateChange={handleNavigationStateChange} onMessage={onMessage} onError={handleError} onHttpError={handleHttpError} injectedJavaScript={injectedJavaScript} injectedJavaScriptBeforeContentLoaded={` - document.body.style.backgroundColor = '#18181b'; - document.documentElement.style.backgroundColor = '#18181b'; + document.body.style.backgroundColor = '${bg}'; + document.documentElement.style.backgroundColor = '${bg}'; `} incognito={false} cacheEnabled={false} @@ -438,9 +440,8 @@ export default function LoginForm() { startInLoadingState={true} sharedCookiesEnabled={false} thirdPartyCookiesEnabled={true} - opaque={false} {...(Platform.OS === "android" && { - mixedContentMode: "always", + mixedContentMode: "always" as const, allowFileAccess: false, })} {...(Platform.OS === "ios" && { @@ -449,7 +450,7 @@ export default function LoginForm() { renderLoading={() => ( - + )} /> diff --git a/app/authentication/ServerForm.tsx b/app/authentication/ServerForm.tsx index a23cbe3..5bdaa50 100644 --- a/app/authentication/ServerForm.tsx +++ b/app/authentication/ServerForm.tsx @@ -1,9 +1,5 @@ import { - TextInput, View, - TouchableOpacity, - Text, - Alert, ScrollView, KeyboardAvoidingView, Platform, @@ -12,11 +8,10 @@ import { useAppContext } from "../AppContext"; import { useState, useEffect } from "react"; import { saveServerConfig, getCurrentServerUrl } from "../main-axios"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Server } from "lucide-react-native"; - -type ServerDetails = { - ip: string; -}; +import { Server, ShieldAlert } from "lucide-react-native"; +import { Text, Input, Button, Label } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; export default function ServerForm() { const { @@ -26,64 +21,38 @@ export default function ServerForm() { selectedServer, } = useAppContext(); const insets = useSafeAreaInsets(); - const [formData, setFormData] = useState({ ip: "" }); + const color = useThemeColor(); + const [serverUrl, setServerUrl] = useState(""); const [isLoading, setIsLoading] = useState(false); useEffect(() => { - const loadExistingConfig = async () => { - try { - const currentUrl = getCurrentServerUrl(); - if (currentUrl) { - setFormData({ ip: currentUrl }); - } else if (selectedServer?.ip) { - setFormData({ ip: selectedServer.ip }); - } - } catch (error) {} - }; - loadExistingConfig(); + const current = getCurrentServerUrl(); + if (current) setServerUrl(current); + else if (selectedServer?.ip) setServerUrl(selectedServer.ip); }, [selectedServer]); - const handleInputChange = (field: keyof ServerDetails, value: string) => { - setFormData((prev) => ({ ...prev, [field]: value })); - }; - const handleConnect = async () => { - const serverUrl = formData.ip.trim(); - if (!serverUrl) { - Alert.alert("Error", "Please enter a server address"); + const url = serverUrl.trim(); + if (!url) { + toast.error("Please enter a server address"); return; } - - if (!/^https?:\/\//.test(serverUrl)) { - Alert.alert( - "Error", - "Server address must start with http:// or https://", - ); + if (!/^https?:\/\//.test(url)) { + toast.error("Server address must start with http:// or https://"); return; } setIsLoading(true); - try { - const serverConfig = { - serverUrl, + await saveServerConfig({ + serverUrl: url, lastUpdated: new Date().toISOString(), - }; - await saveServerConfig(serverConfig); - - const serverInfo = { - name: "Server", - ip: serverUrl, - }; - - setSelectedServer(serverInfo); + }); + setSelectedServer({ name: "Server", ip: url }); setShowServerManager(false); setShowLoginForm(true); } catch (error: any) { - Alert.alert( - "Error", - `Failed to save server: ${error?.message || "Unknown error"}`, - ); + toast.error(`Failed to save server: ${error?.message || "Unknown error"}`); } finally { setIsLoading(false); } @@ -91,66 +60,77 @@ export default function ServerForm() { return ( - - - - Server Connection + + + {/* Brand mark */} + + + + + TERMIX + + + CONNECT TO YOUR SERVER - - - - - - Server Address - - - - - - handleInputChange("ip", value)} - autoCapitalize="none" - autoCorrect={false} - autoComplete="off" - editable={!isLoading} - /> - - - Enter the address of your self-hosted Termix server. - + {/* Card */} + + + + } + onSubmitEditing={handleConnect} + /> + + Enter the address of your self-hosted Termix server, including + http:// or https://. + - - - {isLoading ? "Saving..." : "Connect"} - - + {isLoading ? "Saving…" : "Continue"} + - - + + {/* HTTPS / cert hint */} + + + + Using a self-signed certificate? Install its root CA on your + device first. Local HTTP servers are supported. + + + + ); } diff --git a/app/authentication/UpdateRequired.tsx b/app/authentication/UpdateRequired.tsx index eac1f30..f28cede 100644 --- a/app/authentication/UpdateRequired.tsx +++ b/app/authentication/UpdateRequired.tsx @@ -1,25 +1,18 @@ -import { - View, - Text, - TouchableOpacity, - Alert, - ActivityIndicator, -} from "react-native"; +import { View, ActivityIndicator } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { X, AlertTriangle, Download } from "lucide-react-native"; +import { AlertTriangle, Download } from "lucide-react-native"; import { useAppContext } from "../AppContext"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { getVersionInfo, getLatestGitHubRelease } from "../main-axios"; import { useState, useEffect } from "react"; import Constants from "expo-constants"; +import { Text, Button, Label } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; export default function UpdateRequired() { const insets = useSafeAreaInsets(); + const color = useThemeColor(); const { setShowUpdateScreen } = useAppContext(); - const [versionInfo, setVersionInfo] = useState<{ - localVersion: string; - serverVersion: string; - } | null>(null); const [latestRelease, setLatestRelease] = useState<{ version: string; tagName: string; @@ -32,18 +25,17 @@ export default function UpdateRequired() { useEffect(() => { const fetchVersionInfo = async () => { try { - const [version, release] = await Promise.all([ + const [, release] = await Promise.all([ getVersionInfo(), getLatestGitHubRelease(), ]); - setVersionInfo(version); setLatestRelease(release); - } catch (error) { + } catch { + // best-effort } finally { setIsLoading(false); } }; - fetchVersionInfo(); }, []); @@ -53,8 +45,7 @@ export default function UpdateRequired() { "dismissedUpdateVersion", latestRelease?.version || "unknown", ); - setShowUpdateScreen(false); - } catch (error) { + } finally { setShowUpdateScreen(false); } }; @@ -62,83 +53,75 @@ export default function UpdateRequired() { if (isLoading) { return ( - - - Loading version information... + + + Loading version information… ); } return ( - - - - - Update Required - + + + + + Update Available + - - - - - - Version Mismatch Detected + + + + + + New version available - - A new version of the mobile app is available. Some features may not - work properly until you update to the latest version. + + A newer version of the mobile app is available. Some features may not + work correctly until you update. - - - Version Information: - - - + + + - Current Version: - - {currentMobileAppVersion} + Installed + + v{currentMobileAppVersion} - - Latest Release: - - {latestRelease?.version || "Unknown"} + Latest + + v{latestRelease?.version || "Unknown"} - - {latestRelease?.tagName && ( + {latestRelease?.tagName ? ( - Release Tag: - + Tag + {latestRelease.tagName} - )} + ) : null} - - - - Continue Anyway - - + + ); diff --git a/app/components/CustomTabBar.tsx b/app/components/CustomTabBar.tsx new file mode 100644 index 0000000..1375647 --- /dev/null +++ b/app/components/CustomTabBar.tsx @@ -0,0 +1,78 @@ +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { BottomTabBarProps } from "@react-navigation/bottom-tabs"; +import { Server, SquareTerminal, Settings } from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { useOrientation } from "@/app/utils/orientation"; + +const ICONS: Record< + string, + (props: { color: string; size: number }) => React.ReactNode +> = { + hosts: (p) => , + sessions: (p) => , + settings: (p) => , +}; + +const LABELS: Record = { + hosts: "Hosts", + sessions: "Sessions", + settings: "Settings", +}; + +/** + * Bottom tab bar matching the web MobileBottomBar: square, bg-sidebar, + * top border, accent-colored active item, lucide icons, safe-area aware. + */ +export function CustomTabBar({ state, navigation }: BottomTabBarProps) { + const insets = useSafeAreaInsets(); + const { isLandscape } = useOrientation(); + const resolveColor = useThemeColor(); + const accent = resolveColor("accent-brand") ?? "#f59145"; + const muted = resolveColor("muted-foreground") ?? "#a4a4a4"; + const iconSize = isLandscape ? 18 : 20; + + return ( + + {state.routes.map((route, index) => { + const focused = state.index === index; + const color = focused ? accent : muted; + const icon = ICONS[route.name]; + const label = LABELS[route.name] ?? route.name; + + const onPress = () => { + const event = navigation.emit({ + type: "tabPress", + target: route.key, + canPreventDefault: true, + }); + if (!focused && !event.defaultPrevented) { + navigation.navigate(route.name); + } + }; + + return ( + + {icon?.({ color, size: iconSize })} + + {label} + + + ); + })} + + ); +} diff --git a/app/components/LockScreen.tsx b/app/components/LockScreen.tsx new file mode 100644 index 0000000..07a6847 --- /dev/null +++ b/app/components/LockScreen.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { Lock, Fingerprint, Delete } from "lucide-react-native"; +import { Pressable } from "react-native"; +import { useAppLock } from "@/app/contexts/AppLockContext"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +/** + * Full-screen lock overlay shown when app lock is enabled and the app is + * locked. Offers biometrics (if available) and a numeric PIN keypad. + */ +export function LockScreen() { + const { hasBiometrics, unlockWithBiometrics, unlockWithPin } = useAppLock(); + const color = useThemeColor(); + const [pin, setPin] = useState(""); + const [error, setError] = useState(false); + + useEffect(() => { + // Offer biometrics immediately on appear. + if (hasBiometrics) unlockWithBiometrics(); + }, [hasBiometrics, unlockWithBiometrics]); + + useEffect(() => { + if (pin.length === 4) { + unlockWithPin(pin).then((ok) => { + if (!ok) { + setError(true); + setTimeout(() => { + setPin(""); + setError(false); + }, 600); + } + }); + } + }, [pin, unlockWithPin]); + + const press = (d: string) => { + if (pin.length < 4) setPin((p) => p + d); + }; + + return ( + + + + + + Termix Locked + + + Enter your PIN to continue + + + {/* PIN dots */} + + {[0, 1, 2, 3].map((i) => ( + + ))} + + + {/* Keypad */} + + {[ + ["1", "2", "3"], + ["4", "5", "6"], + ["7", "8", "9"], + ].map((row, ri) => ( + + {row.map((d) => ( + press(d)} /> + ))} + + ))} + + hasBiometrics && unlockWithBiometrics()} + className="w-16 h-16 items-center justify-center" + > + {hasBiometrics ? ( + + ) : null} + + press("0")} /> + setPin((p) => p.slice(0, -1))} + className="w-16 h-16 items-center justify-center border border-border active:bg-muted/40" + > + + + + + + ); +} + +function Key({ label, onPress }: { label: string; onPress: () => void }) { + return ( + + + {label} + + + ); +} diff --git a/app/components/Screen.tsx b/app/components/Screen.tsx new file mode 100644 index 0000000..9385fa5 --- /dev/null +++ b/app/components/Screen.tsx @@ -0,0 +1,47 @@ +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/app/components/ui"; + +/** + * Screen — top-level themed container with safe-area top padding and an + * optional header (title + actions). Used by Hosts / Tools / Settings. + */ +export function Screen({ + title, + subtitle, + headerRight, + children, + scrollableHeader, +}: { + title?: string; + subtitle?: string; + headerRight?: React.ReactNode; + children: React.ReactNode; + /** When true, the header is part of the scroll content (caller handles it). */ + scrollableHeader?: boolean; +}) { + const insets = useSafeAreaInsets(); + + return ( + + {title && !scrollableHeader ? ( + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {headerRight ? ( + {headerRight} + ) : null} + + ) : null} + {children} + + ); +} diff --git a/app/components/ui/Accordion.tsx b/app/components/ui/Accordion.tsx new file mode 100644 index 0000000..cd395da --- /dev/null +++ b/app/components/ui/Accordion.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Pressable, View } from "react-native"; +import { ChevronDown } from "lucide-react-native"; +import { Text } from "./Text"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +/** + * AccordionSection — bordered card with a header that expands/collapses, + * matching the web user-profile accordion (uppercase header + chevron). + */ +export function AccordionSection({ + label, + icon, + defaultOpen = false, + open: controlledOpen, + onToggle, + children, +}: { + label: string; + icon?: React.ReactNode; + defaultOpen?: boolean; + open?: boolean; + onToggle?: () => void; + children: React.ReactNode; +}) { + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const muted = useThemeColor()("muted-foreground"); + + return ( + + { + if (isControlled) onToggle?.(); + else setInternalOpen((o) => !o); + }} + className="flex-row items-center gap-2 px-3 py-3 active:bg-muted/40" + > + {icon ? {icon} : null} + + {label} + + + + + + {open ? ( + {children} + ) : null} + + ); +} diff --git a/app/components/ui/Badge.tsx b/app/components/ui/Badge.tsx new file mode 100644 index 0000000..0149fda --- /dev/null +++ b/app/components/ui/Badge.tsx @@ -0,0 +1,41 @@ +import { View } from "react-native"; +import { Text } from "./Text"; + +export type BadgeVariant = "accent" | "muted" | "destructive" | "success"; + +const VARIANTS: Record = { + accent: { + box: "bg-accent-brand/10 border-accent-brand/40", + text: "text-accent-brand", + }, + muted: { box: "bg-muted border-border", text: "text-muted-foreground" }, + destructive: { + box: "bg-destructive/10 border-destructive/40", + text: "text-destructive", + }, + success: { box: "bg-chart-3/15 border-chart-3/40", text: "text-chart-3" }, +}; + +export function Badge({ + children, + variant = "muted", + className, +}: { + children: React.ReactNode; + variant?: BadgeVariant; + className?: string; +}) { + const v = VARIANTS[variant]; + return ( + + + {children} + + + ); +} diff --git a/app/components/ui/BottomSheet.tsx b/app/components/ui/BottomSheet.tsx new file mode 100644 index 0000000..2496444 --- /dev/null +++ b/app/components/ui/BottomSheet.tsx @@ -0,0 +1,86 @@ +import { Modal, Pressable, View, ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "./Text"; + +/** + * BottomSheet — slide-up themed panel. Used for host action sheets, file ops, + * pickers, and menus. Square top corners, themed surface, scrim backdrop. + */ +export function BottomSheet({ + visible, + onClose, + title, + children, + scroll = false, +}: { + visible: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + scroll?: boolean; +}) { + const insets = useSafeAreaInsets(); + const Container: any = scroll ? ScrollView : View; + + return ( + + + + + + + {title ? ( + + + {title} + + + ) : null} + {children} + + + ); +} + +/** A tappable row inside a BottomSheet (icon + label, optional destructive). */ +export function SheetRow({ + icon, + label, + onPress, + destructive, + trailing, +}: { + icon?: React.ReactNode; + label: string; + onPress: () => void; + destructive?: boolean; + trailing?: React.ReactNode; +}) { + return ( + + {icon ? {icon} : null} + + {label} + + {trailing ?? null} + + ); +} diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx new file mode 100644 index 0000000..7ea66fa --- /dev/null +++ b/app/components/ui/Button.tsx @@ -0,0 +1,95 @@ +import { + Pressable, + type PressableProps, + View, + ActivityIndicator, +} from "react-native"; +import { Text } from "./Text"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +export type ButtonVariant = + | "default" // solid primary + | "accent" // accent-brand outline (primary action in the web design) + | "outline" + | "ghost" + | "destructive"; + +export type ButtonSize = "sm" | "default" | "lg" | "icon"; + +interface ButtonProps extends Omit { + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; + className?: string; + textClassName?: string; + children?: React.ReactNode; + /** Leading icon element (already colored, or it inherits via color prop). */ + icon?: React.ReactNode; +} + +const SIZES: Record = { + sm: "h-8 px-2.5", + default: "h-10 px-3", + lg: "h-12 px-4", + icon: "h-10 w-10", +}; + +const VARIANT_CONTAINER: Record = { + default: "bg-primary border border-primary", + accent: "bg-accent-brand/10 border border-accent-brand/40", + outline: "bg-transparent border border-border", + ghost: "bg-transparent border border-transparent", + destructive: "bg-transparent border border-destructive/40", +}; + +const VARIANT_TEXT: Record = { + default: "text-primary-foreground", + accent: "text-accent-brand", + outline: "text-foreground", + ghost: "text-foreground", + destructive: "text-destructive", +}; + +export function Button({ + variant = "outline", + size = "default", + loading = false, + disabled, + className, + textClassName, + children, + icon, + ...props +}: ButtonProps) { + const accent = useThemeColor()("accent-brand"); + const isDisabled = disabled || loading; + + return ( + + {loading ? ( + + ) : ( + <> + {icon ? {icon} : null} + {typeof children === "string" ? ( + + {children} + + ) : ( + children + )} + + )} + + ); +} diff --git a/app/components/ui/Card.tsx b/app/components/ui/Card.tsx new file mode 100644 index 0000000..624ac5e --- /dev/null +++ b/app/components/ui/Card.tsx @@ -0,0 +1,32 @@ +import { View, type ViewProps } from "react-native"; +import { Text } from "./Text"; + +export function Card({ + className, + ...props +}: ViewProps & { className?: string }) { + return ( + + ); +} + +/** Small uppercase tracking-widest muted label — the web design's section/field label. */ +export function Label({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/app/components/ui/Dialog.tsx b/app/components/ui/Dialog.tsx new file mode 100644 index 0000000..fc14984 --- /dev/null +++ b/app/components/ui/Dialog.tsx @@ -0,0 +1,77 @@ +import { Modal, Pressable, View } from "react-native"; +import { X } from "lucide-react-native"; +import { Text } from "./Text"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +/** + * Dialog — centered modal card. Square corners, themed surface, optional + * title/description and close button. Mirrors the web shadcn dialog. + */ +export function Dialog({ + visible, + onClose, + title, + description, + icon, + children, + footer, +}: { + visible: boolean; + onClose: () => void; + title?: string; + description?: string; + icon?: React.ReactNode; + children?: React.ReactNode; + footer?: React.ReactNode; +}) { + const muted = useThemeColor()("muted-foreground"); + + return ( + + + e.stopPropagation()} + > + {title ? ( + + {icon ? ( + + {icon} + + ) : null} + + + {title} + + {description ? ( + + {description} + + ) : null} + + + + + + ) : null} + {children ? {children} : null} + {footer ? ( + + {footer} + + ) : null} + + + + ); +} diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx new file mode 100644 index 0000000..cd75203 --- /dev/null +++ b/app/components/ui/Input.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from "react"; +import { TextInput, type TextInputProps, View } from "react-native"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +interface InputProps extends TextInputProps { + className?: string; + /** Optional leading element (e.g. a search icon). */ + leading?: React.ReactNode; + /** Optional trailing element (e.g. a clear button). */ + trailing?: React.ReactNode; + containerClassName?: string; +} + +export const Input = forwardRef(function Input( + { className, leading, trailing, containerClassName, style, ...props }, + ref, +) { + const placeholderColor = useThemeColor()("muted-foreground", 0.7); + + return ( + + {leading ? {leading} : null} + + {trailing ? {trailing} : null} + + ); +}); diff --git a/app/components/ui/SegmentedControl.tsx b/app/components/ui/SegmentedControl.tsx new file mode 100644 index 0000000..acca6c1 --- /dev/null +++ b/app/components/ui/SegmentedControl.tsx @@ -0,0 +1,40 @@ +import { Pressable, View } from "react-native"; +import { Text } from "./Text"; + +/** + * SegmentedControl — a row of equal-width options where the selected one is + * highlighted with the accent (matches the web font-size / theme pickers). + */ +export function SegmentedControl({ + options, + value, + onChange, + className, +}: { + options: { id: T; label: string }[]; + value: T; + onChange: (id: T) => void; + className?: string; +}) { + return ( + + {options.map((opt) => { + const active = opt.id === value; + return ( + onChange(opt.id)} + className={`flex-1 py-2 items-center border ${active ? "bg-accent-brand/10 border-accent-brand/40" : "border-border active:bg-muted/40"}`} + > + + {opt.label} + + + ); + })} + + ); +} diff --git a/app/components/ui/SettingRow.tsx b/app/components/ui/SettingRow.tsx new file mode 100644 index 0000000..4fba5a2 --- /dev/null +++ b/app/components/ui/SettingRow.tsx @@ -0,0 +1,50 @@ +import { View } from "react-native"; +import { Text } from "./Text"; + +/** + * SettingRow — label + optional description on the left, a control on the + * right. Mirrors the web app's SettingRow used throughout user preferences. + */ +export function SettingRow({ + label, + description, + badge, + children, + last, +}: { + label: string; + description?: string; + badge?: string; + children: React.ReactNode; + last?: boolean; +}) { + return ( + + + + + {label} + + {badge ? ( + + + {badge} + + + ) : null} + + {description ? ( + + {description} + + ) : null} + + {children} + + ); +} diff --git a/app/components/ui/Switch.tsx b/app/components/ui/Switch.tsx new file mode 100644 index 0000000..101fe83 --- /dev/null +++ b/app/components/ui/Switch.tsx @@ -0,0 +1,59 @@ +import { Pressable, View } from "react-native"; +import Animated, { + useAnimatedStyle, + withTiming, +} from "react-native-reanimated"; + +/** + * FakeSwitch — pill toggle matching the web design (bg-accent-brand when on, + * bg-muted when off). One of the few intentionally rounded elements. + */ +export function FakeSwitch({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + const thumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: withTiming(checked ? 16 : 2, { duration: 150 }) }], + })); + + return ( + onChange(!checked)} + hitSlop={8} + className={`w-9 h-5 rounded-full justify-center ${checked ? "bg-accent-brand" : "bg-muted"} ${disabled ? "opacity-50" : ""}`} + > + + + ); +} + +/** Square checkbox with 4px rounding (matches web). */ +export function Checkbox({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + onChange(!checked)} + hitSlop={8} + className={`w-4 h-4 rounded-check border items-center justify-center ${checked ? "bg-accent-brand border-accent-brand" : "border-input bg-transparent"} ${disabled ? "opacity-50" : ""}`} + > + {checked ? : null} + + ); +} diff --git a/app/components/ui/Text.tsx b/app/components/ui/Text.tsx new file mode 100644 index 0000000..a041f99 --- /dev/null +++ b/app/components/ui/Text.tsx @@ -0,0 +1,36 @@ +import { Text as RNText, type TextProps as RNTextProps } from "react-native"; +import { MONO_FONT, MONO_FONT_BOLD, MONO_FONT_MEDIUM } from "@/app/constants/fonts"; + +export type TextWeight = "regular" | "medium" | "bold"; + +export interface ThemedTextProps extends RNTextProps { + weight?: TextWeight; + /** Tailwind classes (color, size, etc.). Defaults to foreground. */ + className?: string; +} + +const FONT_BY_WEIGHT: Record = { + regular: MONO_FONT, + medium: MONO_FONT_MEDIUM, + bold: MONO_FONT_BOLD, +}; + +/** + * App-wide text. Always monospace (JetBrains Mono), defaults to the theme + * foreground color. Use `weight` for medium/bold since RN cannot synthesize + * weights for custom fonts. + */ +export function Text({ + weight = "regular", + className, + style, + ...props +}: ThemedTextProps) { + return ( + + ); +} diff --git a/app/components/ui/index.ts b/app/components/ui/index.ts new file mode 100644 index 0000000..51f3366 --- /dev/null +++ b/app/components/ui/index.ts @@ -0,0 +1,14 @@ +export { Text } from "./Text"; +export type { ThemedTextProps, TextWeight } from "./Text"; +export { Button } from "./Button"; +export type { ButtonVariant, ButtonSize } from "./Button"; +export { Input } from "./Input"; +export { Card, Label } from "./Card"; +export { FakeSwitch, Checkbox } from "./Switch"; +export { SettingRow } from "./SettingRow"; +export { AccordionSection } from "./Accordion"; +export { BottomSheet, SheetRow } from "./BottomSheet"; +export { Dialog } from "./Dialog"; +export { Badge } from "./Badge"; +export type { BadgeVariant } from "./Badge"; +export { SegmentedControl } from "./SegmentedControl"; diff --git a/app/constants/designTokens.ts b/app/constants/designTokens.ts index 4e18735..dcfd4b3 100644 --- a/app/constants/designTokens.ts +++ b/app/constants/designTokens.ts @@ -1,40 +1,49 @@ /** - * Includes a default value for all margins, borders, design elements, etc. - * These can be used across all components as a default inside the style tag in a component - * Any styling not included as a default here, can be set inside a className using NativeWind + * Static design tokens for the dark session surfaces (terminal, stats, file + * manager, tunnels, tab bar). These mirror the redesigned web app's dark + * theme. They are intentionally static (not theme-context driven) because the + * terminal/console area stays dark in every app theme — matching the web, + * where the terminal canvas is always dark. For themable chrome elsewhere in + * the app, use Tailwind tokens + useTheme() instead. */ export const BORDERS = { - MAJOR: 2, + MAJOR: 1, STANDARD: 1, SEPARATOR: 1, } as const; +// Brand accent (orange) — replaces the old terminal green. +export const ACCENT = "#f59145"; + export const BORDER_COLORS = { - PRIMARY: "#303032", - SECONDARY: "#373739", - SEPARATOR: "#404040", - BUTTON: "#303032", - ACTIVE: "#22C55E", + PRIMARY: "#323232", + SECONDARY: "#383838", + SEPARATOR: "#2a2a2a", + BUTTON: "#323232", + PANEL: "#262626", + ACTIVE: ACCENT, } as const; export const BACKGROUNDS = { - DARKEST: "#09090b", - DARKER: "#0e0e10", - HEADER: "#131316", - DARK: "#18181b", - CARD: "#1a1a1a", - BUTTON: "#2a2a2a", - BUTTON_ALT: "#23232a", - ACTIVE: "#4a4a4a", - HOVER: "#2d2d30", + DARKEST: "#0c0d0b", + DARKER: "#141513", + HEADER: "#141513", + DARK: "#0c0d0b", + CARD: "#181917", + PANEL: "#181917", + BUTTON: "#232323", + BUTTON_ALT: "#232323", + ACTIVE: "#2a2a2a", + HOVER: "#232323", } as const; export const RADIUS = { - BUTTON: 6, - CARD: 12, - SMALL: 4, - LARGE: 16, + // Square corners everywhere — matches the web redesign. + BUTTON: 0, + CARD: 0, + SMALL: 0, + LARGE: 0, } as const; export const SPACING = { @@ -47,11 +56,11 @@ export const SPACING = { } as const; export const TEXT_COLORS = { - PRIMARY: "#ffffff", - SECONDARY: "#9CA3AF", - TERTIARY: "#6B7280", - DISABLED: "#4B5563", - ACCENT: "#22C55E", + PRIMARY: "#fafafa", + SECONDARY: "#a4a4a4", + TERTIARY: "#737373", + DISABLED: "#525252", + ACCENT, } as const; export const ICON_SIZES = { diff --git a/app/constants/fonts.ts b/app/constants/fonts.ts new file mode 100644 index 0000000..98d8178 --- /dev/null +++ b/app/constants/fonts.ts @@ -0,0 +1,21 @@ +import { + JetBrainsMono_400Regular, + JetBrainsMono_500Medium, + JetBrainsMono_700Bold, +} from "@expo-google-fonts/jetbrains-mono"; + +/** + * Font families registered with expo-font. We register weight-specific + * families because React Native cannot synthesize weights for custom fonts. + * Tailwind `font-mono` resolves to "JetBrainsMono" (see tailwind.config.js); + * for bold/medium text, set fontFamily explicitly via these names. + */ +export const FONT_MAP = { + JetBrainsMono: JetBrainsMono_400Regular, + "JetBrainsMono-Medium": JetBrainsMono_500Medium, + "JetBrainsMono-Bold": JetBrainsMono_700Bold, +}; + +export const MONO_FONT = "JetBrainsMono"; +export const MONO_FONT_MEDIUM = "JetBrainsMono-Medium"; +export const MONO_FONT_BOLD = "JetBrainsMono-Bold"; diff --git a/app/constants/theme.ts b/app/constants/theme.ts new file mode 100644 index 0000000..7414f4c --- /dev/null +++ b/app/constants/theme.ts @@ -0,0 +1,461 @@ +/** + * Theme constants — ported from the Termix web app + * (../Termix/Termix/src/ui/lib/theme.ts + index.css). + * + * Colors are space-separated RGB triplets ("R G B") so they can be fed to + * NativeWind's vars() helper and resolved by Tailwind tokens that use + * rgb(var(--token) / ). + */ + +export type ThemeId = + | "system" + | "light" + | "dark" + | "dracula" + | "catppuccin" + | "nord" + | "solarized" + | "tokyo-night" + | "one-dark" + | "gruvbox"; + +export type FontSizeId = "xs" | "sm" | "md" | "lg" | "xl"; + +/** Full variable set keyed by token name (without the leading --). */ +export type ThemeVars = Record; + +const LIGHT: ThemeVars = { + background: "255 255 255", + foreground: "17 18 16", + card: "255 255 255", + "card-foreground": "17 18 16", + popover: "255 255 255", + "popover-foreground": "17 18 16", + primary: "24 25 23", + "primary-foreground": "250 250 250", + secondary: "245 245 245", + "secondary-foreground": "24 25 23", + muted: "245 245 245", + "muted-foreground": "115 115 115", + accent: "245 245 245", + "accent-foreground": "24 25 23", + destructive: "231 0 11", + border: "229 229 229", + input: "229 229 229", + ring: "161 161 161", + surface: "245 245 245", + "surface-dim": "235 235 235", + "chart-1": "212 212 212", + "chart-2": "115 115 115", + "chart-3": "82 82 82", + "chart-4": "64 64 64", + "chart-5": "38 38 38", + sidebar: "250 250 250", + "sidebar-foreground": "17 18 16", + "sidebar-primary": "24 25 23", + "sidebar-primary-foreground": "250 250 250", + "sidebar-accent": "245 245 245", + "sidebar-accent-foreground": "24 25 23", + "sidebar-border": "229 229 229", + "sidebar-ring": "161 161 161", +}; + +const DARK: ThemeVars = { + background: "12 13 11", + foreground: "250 250 250", + card: "24 25 23", + "card-foreground": "250 250 250", + popover: "24 25 23", + "popover-foreground": "250 250 250", + primary: "229 229 229", + "primary-foreground": "24 25 23", + secondary: "38 38 38", + "secondary-foreground": "250 250 250", + muted: "35 35 35", + "muted-foreground": "164 164 164", + accent: "35 35 35", + "accent-foreground": "250 250 250", + destructive: "255 100 103", + border: "50 50 50", + input: "56 56 56", + ring: "115 115 115", + surface: "18 19 17", + "surface-dim": "14 15 13", + "chart-1": "212 212 212", + "chart-2": "115 115 115", + "chart-3": "82 82 82", + "chart-4": "64 64 64", + "chart-5": "38 38 38", + sidebar: "20 21 19", + "sidebar-foreground": "250 250 250", + "sidebar-primary": "20 71 230", + "sidebar-primary-foreground": "250 250 250", + "sidebar-accent": "35 35 35", + "sidebar-accent-foreground": "250 250 250", + "sidebar-border": "50 50 50", + "sidebar-ring": "115 115 115", +}; + +const DRACULA: ThemeVars = { + background: "40 42 54", + foreground: "248 248 242", + card: "33 34 44", + "card-foreground": "248 248 242", + popover: "33 34 44", + "popover-foreground": "248 248 242", + primary: "248 248 242", + "primary-foreground": "40 42 54", + secondary: "68 71 90", + "secondary-foreground": "248 248 242", + muted: "68 71 90", + "muted-foreground": "98 114 164", + accent: "68 71 90", + "accent-foreground": "248 248 242", + destructive: "255 85 85", + border: "98 114 164", + input: "98 114 164", + ring: "98 114 164", + surface: "33 34 44", + "surface-dim": "25 26 33", + "chart-1": "255 121 198", + "chart-2": "139 233 253", + "chart-3": "80 250 123", + "chart-4": "255 184 108", + "chart-5": "189 147 249", + sidebar: "33 34 44", + "sidebar-foreground": "248 248 242", + "sidebar-primary": "189 147 249", + "sidebar-primary-foreground": "40 42 54", + "sidebar-accent": "68 71 90", + "sidebar-accent-foreground": "248 248 242", + "sidebar-border": "98 114 164", + "sidebar-ring": "98 114 164", +}; + +const CATPPUCCIN: ThemeVars = { + background: "30 30 46", + foreground: "205 214 244", + card: "24 24 37", + "card-foreground": "205 214 244", + popover: "24 24 37", + "popover-foreground": "205 214 244", + primary: "205 214 244", + "primary-foreground": "30 30 46", + secondary: "49 50 68", + "secondary-foreground": "205 214 244", + muted: "49 50 68", + "muted-foreground": "108 112 134", + accent: "49 50 68", + "accent-foreground": "205 214 244", + destructive: "243 139 168", + border: "108 112 134", + input: "108 112 134", + ring: "108 112 134", + surface: "24 24 37", + "surface-dim": "17 17 27", + "chart-1": "243 139 168", + "chart-2": "137 220 235", + "chart-3": "166 227 161", + "chart-4": "250 179 135", + "chart-5": "203 166 247", + sidebar: "24 24 37", + "sidebar-foreground": "205 214 244", + "sidebar-primary": "203 166 247", + "sidebar-primary-foreground": "30 30 46", + "sidebar-accent": "49 50 68", + "sidebar-accent-foreground": "205 214 244", + "sidebar-border": "108 112 134", + "sidebar-ring": "108 112 134", +}; + +const NORD: ThemeVars = { + background: "46 52 64", + foreground: "236 239 244", + card: "39 44 54", + "card-foreground": "236 239 244", + popover: "39 44 54", + "popover-foreground": "236 239 244", + primary: "236 239 244", + "primary-foreground": "46 52 64", + secondary: "59 66 82", + "secondary-foreground": "236 239 244", + muted: "59 66 82", + "muted-foreground": "76 86 106", + accent: "59 66 82", + "accent-foreground": "236 239 244", + destructive: "191 97 106", + border: "76 86 106", + input: "76 86 106", + ring: "76 86 106", + surface: "39 44 54", + "surface-dim": "34 38 46", + "chart-1": "191 97 106", + "chart-2": "136 192 208", + "chart-3": "163 190 140", + "chart-4": "235 203 139", + "chart-5": "180 142 173", + sidebar: "39 44 54", + "sidebar-foreground": "236 239 244", + "sidebar-primary": "136 192 208", + "sidebar-primary-foreground": "46 52 64", + "sidebar-accent": "59 66 82", + "sidebar-accent-foreground": "236 239 244", + "sidebar-border": "76 86 106", + "sidebar-ring": "76 86 106", +}; + +const SOLARIZED: ThemeVars = { + background: "0 43 54", + foreground: "131 148 150", + card: "7 54 66", + "card-foreground": "147 161 161", + popover: "7 54 66", + "popover-foreground": "147 161 161", + primary: "147 161 161", + "primary-foreground": "0 43 54", + secondary: "7 54 66", + "secondary-foreground": "131 148 150", + muted: "7 54 66", + "muted-foreground": "88 110 117", + accent: "7 54 66", + "accent-foreground": "147 161 161", + destructive: "220 50 47", + border: "88 110 117", + input: "88 110 117", + ring: "88 110 117", + surface: "7 54 66", + "surface-dim": "0 33 43", + "chart-1": "220 50 47", + "chart-2": "42 161 152", + "chart-3": "133 153 0", + "chart-4": "181 137 0", + "chart-5": "108 113 196", + sidebar: "7 54 66", + "sidebar-foreground": "147 161 161", + "sidebar-primary": "38 139 210", + "sidebar-primary-foreground": "0 43 54", + "sidebar-accent": "7 54 66", + "sidebar-accent-foreground": "147 161 161", + "sidebar-border": "88 110 117", + "sidebar-ring": "88 110 117", +}; + +const TOKYO_NIGHT: ThemeVars = { + background: "26 27 38", + foreground: "169 177 214", + card: "22 22 30", + "card-foreground": "169 177 214", + popover: "22 22 30", + "popover-foreground": "169 177 214", + primary: "169 177 214", + "primary-foreground": "26 27 38", + secondary: "36 40 59", + "secondary-foreground": "169 177 214", + muted: "36 40 59", + "muted-foreground": "86 95 137", + accent: "36 40 59", + "accent-foreground": "169 177 214", + destructive: "247 118 142", + border: "86 95 137", + input: "86 95 137", + ring: "86 95 137", + surface: "22 22 30", + "surface-dim": "19 19 26", + "chart-1": "247 118 142", + "chart-2": "125 207 255", + "chart-3": "158 206 106", + "chart-4": "224 175 104", + "chart-5": "187 154 247", + sidebar: "22 22 30", + "sidebar-foreground": "169 177 214", + "sidebar-primary": "122 162 247", + "sidebar-primary-foreground": "26 27 38", + "sidebar-accent": "36 40 59", + "sidebar-accent-foreground": "169 177 214", + "sidebar-border": "86 95 137", + "sidebar-ring": "86 95 137", +}; + +const ONE_DARK: ThemeVars = { + background: "40 44 52", + foreground: "171 178 191", + card: "33 37 43", + "card-foreground": "171 178 191", + popover: "33 37 43", + "popover-foreground": "171 178 191", + primary: "171 178 191", + "primary-foreground": "40 44 52", + secondary: "44 49 58", + "secondary-foreground": "171 178 191", + muted: "44 49 58", + "muted-foreground": "92 99 112", + accent: "44 49 58", + "accent-foreground": "171 178 191", + destructive: "224 108 117", + border: "92 99 112", + input: "92 99 112", + ring: "92 99 112", + surface: "33 37 43", + "surface-dim": "27 31 35", + "chart-1": "224 108 117", + "chart-2": "86 182 194", + "chart-3": "152 195 121", + "chart-4": "229 192 123", + "chart-5": "198 120 221", + sidebar: "33 37 43", + "sidebar-foreground": "171 178 191", + "sidebar-primary": "97 175 239", + "sidebar-primary-foreground": "40 44 52", + "sidebar-accent": "44 49 58", + "sidebar-accent-foreground": "171 178 191", + "sidebar-border": "92 99 112", + "sidebar-ring": "92 99 112", +}; + +const GRUVBOX: ThemeVars = { + background: "40 40 40", + foreground: "235 219 178", + card: "29 32 33", + "card-foreground": "235 219 178", + popover: "29 32 33", + "popover-foreground": "235 219 178", + primary: "235 219 178", + "primary-foreground": "40 40 40", + secondary: "60 56 54", + "secondary-foreground": "235 219 178", + muted: "60 56 54", + "muted-foreground": "146 131 116", + accent: "60 56 54", + "accent-foreground": "235 219 178", + destructive: "204 36 29", + border: "146 131 116", + input: "146 131 116", + ring: "146 131 116", + surface: "29 32 33", + "surface-dim": "24 26 26", + "chart-1": "204 36 29", + "chart-2": "104 157 106", + "chart-3": "152 151 26", + "chart-4": "215 153 33", + "chart-5": "177 98 134", + sidebar: "29 32 33", + "sidebar-foreground": "235 219 178", + "sidebar-primary": "250 189 47", + "sidebar-primary-foreground": "40 40 40", + "sidebar-accent": "60 56 54", + "sidebar-accent-foreground": "235 219 178", + "sidebar-border": "146 131 116", + "sidebar-ring": "146 131 116", +}; + +/** Resolved variable map per concrete theme (excludes "system"). */ +export const THEME_VARS: Record, ThemeVars> = { + light: LIGHT, + dark: DARK, + dracula: DRACULA, + catppuccin: CATPPUCCIN, + nord: NORD, + solarized: SOLARIZED, + "tokyo-night": TOKYO_NIGHT, + "one-dark": ONE_DARK, + gruvbox: GRUVBOX, +}; + +/** Whether a concrete theme is dark (drives status bar + xterm defaults). */ +export const THEME_IS_DARK: Record, boolean> = { + light: false, + dark: true, + dracula: true, + catppuccin: true, + nord: true, + solarized: true, + "tokyo-night": true, + "one-dark": true, + gruvbox: true, +}; + +/** Theme picker entries (preview is a swatch hex). Mirrors web UserProfilePanel. */ +export const THEMES: { id: ThemeId; preview: string }[] = [ + { id: "system", preview: "auto" }, + { id: "light", preview: "#ffffff" }, + { id: "dark", preview: "#0c0d0b" }, + { id: "dracula", preview: "#282a36" }, + { id: "catppuccin", preview: "#1e1e2e" }, + { id: "nord", preview: "#2e3440" }, + { id: "solarized", preview: "#002b36" }, + { id: "tokyo-night", preview: "#1a1b26" }, + { id: "one-dark", preview: "#282c34" }, + { id: "gruvbox", preview: "#282828" }, +]; + +export const THEME_LABELS: Record = { + system: "System", + light: "Light", + dark: "Dark", + dracula: "Dracula", + catppuccin: "Catppuccin", + nord: "Nord", + solarized: "Solarized", + "tokyo-night": "Tokyo Night", + "one-dark": "One Dark", + gruvbox: "Gruvbox", +}; + +/** 12 accent presets — identical set + order to the web app. */ +export const ACCENT_PRESET_COLORS = [ + { label: "Orange", value: "#f59145" }, + { label: "Blue", value: "#3b82f6" }, + { label: "Green", value: "#22c55e" }, + { label: "Purple", value: "#a855f7" }, + { label: "Pink", value: "#ec4899" }, + { label: "Cyan", value: "#06b6d4" }, + { label: "Red", value: "#ef4444" }, + { label: "Yellow", value: "#eab308" }, + { label: "Teal", value: "#14b8a6" }, + { label: "Indigo", value: "#6366f1" }, + { label: "Rose", value: "#f43f5e" }, + { label: "Lime", value: "#84cc16" }, +]; + +export const DEFAULT_ACCENT = "#f59145"; + +export const FONT_SIZES: { id: FontSizeId; label: string; px: number }[] = [ + { id: "xs", label: "XS", px: 11 }, + { id: "sm", label: "Small", px: 12 }, + { id: "md", label: "Normal", px: 14 }, + { id: "lg", label: "Large", px: 16 }, + { id: "xl", label: "XL", px: 18 }, +]; + +export const FOLDER_COLORS = [ + "#ef4444", + "#f97316", + "#eab308", + "#22c55e", + "#3b82f6", + "#a855f7", + "#ec4899", + "#6b7280", +]; + +/** "#f59145" -> "245 145 69" (RGB triplet for vars()). Returns null if invalid. */ +export function hexToRgbTriplet(hex: string): string | null { + let h = hex.trim().replace("#", ""); + if (h.length === 3) + h = h + .split("") + .map((c) => c + c) + .join(""); + if (!/^[0-9a-fA-F]{6}$/.test(h)) return null; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `${r} ${g} ${b}`; +} + +/** AsyncStorage keys (mirror the web localStorage keys conceptually). */ +export const STORAGE_KEYS = { + theme: "termix-theme", + accent: "termix-accent", + fontSize: "termix-font-size", +} as const; diff --git a/app/contexts/AppLockContext.tsx b/app/contexts/AppLockContext.tsx new file mode 100644 index 0000000..97816f0 --- /dev/null +++ b/app/contexts/AppLockContext.tsx @@ -0,0 +1,138 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as LocalAuthentication from "expo-local-authentication"; +import * as SecureStore from "expo-secure-store"; + +/** + * App Lock — optional biometric / PIN gate when the app is opened or returns + * from background. Addresses Support issues #496 / #338 (account security on + * mobile). The PIN is stored in the OS secure store; the enabled flag in + * AsyncStorage. + */ + +const ENABLED_KEY = "appLockEnabled"; +const PIN_KEY = "appLockPin"; // SecureStore +const LOCK_DELAY_MS = 15_000; // re-lock if backgrounded longer than this + +interface AppLockContextValue { + enabled: boolean; + locked: boolean; + hasBiometrics: boolean; + /** Enable app lock with a numeric PIN (biometrics used when available). */ + enable: (pin: string) => Promise; + disable: () => Promise; + /** Attempt unlock via biometrics; returns success. */ + unlockWithBiometrics: () => Promise; + /** Attempt unlock via PIN; returns success. */ + unlockWithPin: (pin: string) => Promise; +} + +const AppLockContext = createContext(null); + +export function AppLockProvider({ children }: { children: React.ReactNode }) { + const [enabled, setEnabled] = useState(false); + const [locked, setLocked] = useState(false); + const [hasBiometrics, setHasBiometrics] = useState(false); + const backgroundedAt = useRef(null); + const appState = useRef(AppState.currentState); + + useEffect(() => { + (async () => { + const [isEnabled, hw] = await Promise.all([ + AsyncStorage.getItem(ENABLED_KEY), + LocalAuthentication.hasHardwareAsync().catch(() => false), + ]); + const on = isEnabled === "true"; + setEnabled(on); + setHasBiometrics(!!hw); + if (on) setLocked(true); // lock on cold start + })(); + }, []); + + // Re-lock when returning from background after the delay. + useEffect(() => { + const sub = AppState.addEventListener("change", (next: AppStateStatus) => { + const prev = appState.current; + appState.current = next; + if (!enabled) return; + if (next.match(/inactive|background/)) { + backgroundedAt.current = Date.now(); + } else if (prev.match(/inactive|background/) && next === "active") { + const elapsed = Date.now() - (backgroundedAt.current ?? 0); + if (elapsed >= LOCK_DELAY_MS) setLocked(true); + } + }); + return () => sub.remove(); + }, [enabled]); + + const enable = useCallback(async (pin: string) => { + await SecureStore.setItemAsync(PIN_KEY, pin); + await AsyncStorage.setItem(ENABLED_KEY, "true"); + setEnabled(true); + setLocked(false); + }, []); + + const disable = useCallback(async () => { + await SecureStore.deleteItemAsync(PIN_KEY).catch(() => {}); + await AsyncStorage.setItem(ENABLED_KEY, "false"); + setEnabled(false); + setLocked(false); + }, []); + + const unlockWithBiometrics = useCallback(async () => { + try { + const enrolled = await LocalAuthentication.isEnrolledAsync(); + if (!enrolled) return false; + const res = await LocalAuthentication.authenticateAsync({ + promptMessage: "Unlock Termix", + fallbackLabel: "Use PIN", + }); + if (res.success) { + setLocked(false); + return true; + } + return false; + } catch { + return false; + } + }, []); + + const unlockWithPin = useCallback(async (pin: string) => { + const stored = await SecureStore.getItemAsync(PIN_KEY).catch(() => null); + if (stored && stored === pin) { + setLocked(false); + return true; + } + return false; + }, []); + + return ( + + {children} + + ); +} + +export function useAppLock(): AppLockContextValue { + const ctx = useContext(AppLockContext); + if (!ctx) throw new Error("useAppLock must be used within AppLockProvider"); + return ctx; +} diff --git a/app/contexts/KeyboardCustomizationContext.tsx b/app/contexts/KeyboardCustomizationContext.tsx index f3398d3..a8fd6fc 100644 --- a/app/contexts/KeyboardCustomizationContext.tsx +++ b/app/contexts/KeyboardCustomizationContext.tsx @@ -14,7 +14,6 @@ import { KeyboardSettings, } from "@/types/keyboard"; import { - PRESET_DEFINITIONS, getPresetById, } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; diff --git a/app/contexts/TerminalSessionsContext.tsx b/app/contexts/TerminalSessionsContext.tsx index c4a01a1..36c40cd 100644 --- a/app/contexts/TerminalSessionsContext.tsx +++ b/app/contexts/TerminalSessionsContext.tsx @@ -10,12 +10,20 @@ import React, { import { SSHHost } from "@/types"; import { router } from "expo-router"; import { useAppContext } from "@/app/AppContext"; +import { + addOpenTab, + deleteOpenTab, + getOpenTabs, + patchOpenTab, + type OpenTabRecord, +} from "@/app/main-axios"; export type SessionType = | "terminal" | "stats" | "filemanager" | "tunnel" + | "docker" | "remoteDesktop"; export interface TerminalSession { @@ -25,15 +33,62 @@ export interface TerminalSession { isActive: boolean; createdAt: Date; type: SessionType; + /** Stable per-tab instance id used for cross-device tab sync (open-tabs API). */ + instanceId: string; + /** Backend SSH session id this tab is attached to, when resumed/created. */ + backendSessionId?: string | null; + /** When set, the terminal should attach to this backend session on connect. */ + restoredSessionId?: string | null; +} + +const TYPE_LABELS: Record = { + terminal: "", + stats: "Stats", + filemanager: "Files", + tunnel: "Tunnels", + docker: "Docker", + remoteDesktop: "Remote", +}; + +/** open-tabs tabType is shared with the web app; map our session types to it. */ +function toTabType(type: SessionType): string { + switch (type) { + case "filemanager": + return "files"; + case "remoteDesktop": + return "rdp"; + default: + return type; + } +} + +function uuid(): string { + // RN-safe UUID v4 (crypto.randomUUID is not always available). + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); } interface TerminalSessionsContextType { sessions: TerminalSession[]; activeSessionId: string | null; - addSession: (host: SSHHost, type?: SessionType) => string; + /** Background tab records that exist server-side but aren't open here. */ + backgroundTabRecords: OpenTabRecord[]; + refreshBackgroundTabs: () => Promise; + addSession: ( + host: SSHHost, + type?: SessionType, + opts?: { instanceId?: string; restoredSessionId?: string | null }, + ) => string; removeSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void; clearAllSessions: () => void; + /** Persist the backend session id created/attached for a tab (cross-device). */ + setBackendSessionId: (sessionId: string, backendId: string | null) => void; + /** Forget a server-side background tab record. */ + forgetBackgroundTab: (recordId: string) => void; navigateToSessions: (host?: SSHHost, type?: SessionType) => void; isCustomKeyboardVisible: boolean; toggleCustomKeyboard: () => void; @@ -67,28 +122,34 @@ export const TerminalSessionsProvider: React.FC< const { isAuthenticated } = useAppContext(); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); + const [backgroundTabRecords, setBackgroundTabRecords] = useState< + OpenTabRecord[] + >([]); const [isCustomKeyboardVisible, setIsCustomKeyboardVisible] = useState(false); const [lastKeyboardHeight, setLastKeyboardHeight] = useState(300); const keyboardIntentionallyHiddenRef = useRef(false); const [, forceUpdate] = useState({}); + const refreshBackgroundTabs = useCallback(async () => { + const records = await getOpenTabs(); + setBackgroundTabRecords(records); + }, []); + const addSession = useCallback( - (host: SSHHost, type: SessionType = "terminal"): string => { + ( + host: SSHHost, + type: SessionType = "terminal", + opts?: { instanceId?: string; restoredSessionId?: string | null }, + ): string => { + const instanceId = opts?.instanceId ?? uuid(); + const sessionId = `${host.id}-${type}-${Date.now()}`; + setSessions((prev) => { const existingSessions = prev.filter( (session) => session.host.id === host.id && session.type === type, ); - const typeLabel = - type === "stats" - ? "Stats" - : type === "filemanager" - ? "Files" - : type === "tunnel" - ? "Tunnels" - : type === "remoteDesktop" - ? "Remote" - : ""; + const typeLabel = TYPE_LABELS[type]; let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; if (existingSessions.length > 0) { title = typeLabel @@ -96,7 +157,6 @@ export const TerminalSessionsProvider: React.FC< : `${host.name} (${existingSessions.length + 1})`; } - const sessionId = `${host.id}-${type}-${Date.now()}`; const newSession: TerminalSession = { id: sessionId, host, @@ -104,18 +164,30 @@ export const TerminalSessionsProvider: React.FC< isActive: true, createdAt: new Date(), type, + instanceId, + backendSessionId: opts?.restoredSessionId ?? null, + restoredSessionId: opts?.restoredSessionId ?? null, }; + // Persist this tab server-side for cross-device awareness. + addOpenTab({ + id: instanceId, + tabType: toTabType(type), + hostId: host.id, + label: title, + tabOrder: prev.length, + backendSessionId: opts?.restoredSessionId ?? null, + }); + const updatedSessions = prev.map((session) => ({ ...session, isActive: false, })); - - setActiveSessionId(sessionId); return [...updatedSessions, newSession]; }); - return ""; + setActiveSessionId(sessionId); + return sessionId; }, [], ); @@ -128,6 +200,9 @@ export const TerminalSessionsProvider: React.FC< ); if (!sessionToRemove) return prev; + // Forget the server-side record for this tab. + deleteOpenTab(sessionToRemove.instanceId); + const updatedSessions = prev.filter( (session) => session.id !== sessionId, ); @@ -149,16 +224,7 @@ export const TerminalSessionsProvider: React.FC< (s) => s.id === session.id, ); if (sessionIndex !== -1) { - const typeLabel = - session.type === "stats" - ? "Stats" - : session.type === "filemanager" - ? "Files" - : session.type === "tunnel" - ? "Tunnels" - : session.type === "remoteDesktop" - ? "Remote" - : ""; + const typeLabel = TYPE_LABELS[session.type]; const baseName = typeLabel ? `${session.host.name} - ${typeLabel}` : session.host.name; @@ -209,6 +275,26 @@ export const TerminalSessionsProvider: React.FC< [isCustomKeyboardVisible], ); + const setBackendSessionId = useCallback( + (sessionId: string, backendId: string | null) => { + setSessions((prev) => { + const target = prev.find((s) => s.id === sessionId); + if (!target) return prev; + patchOpenTab(target.instanceId, { backendSessionId: backendId }); + return prev.map((s) => + s.id === sessionId + ? { ...s, backendSessionId: backendId, restoredSessionId: null } + : s, + ); + }); + }, + [], + ); + + const forgetBackgroundTab = useCallback((recordId: string) => { + setBackgroundTabRecords((prev) => prev.filter((r) => r.id !== recordId)); + }, []); + const navigateToSessions = useCallback( (host?: SSHHost, type: SessionType = "terminal") => { if (host) { @@ -231,6 +317,7 @@ export const TerminalSessionsProvider: React.FC< const clearAllSessions = useCallback(() => { setSessions([]); setActiveSessionId(null); + setBackgroundTabRecords([]); setIsCustomKeyboardVisible(false); keyboardIntentionallyHiddenRef.current = false; }, []); @@ -238,18 +325,25 @@ export const TerminalSessionsProvider: React.FC< useEffect(() => { if (!isAuthenticated) { clearAllSessions(); + } else { + // Hydrate background tab records (e.g. tabs opened on another device). + refreshBackgroundTabs(); } - }, [isAuthenticated, clearAllSessions]); + }, [isAuthenticated, clearAllSessions, refreshBackgroundTabs]); return ( ; + isDark: boolean; + accent: string; // hex, e.g. "#f59145" + accentTriplet: string; // "245 145 69" + fontSize: FontSizeId; + fontScale: number; // multiplier vs the 14px baseline + ready: boolean; + setTheme: (t: ThemeId) => void; + setAccent: (hex: string) => void; + setFontSize: (id: FontSizeId) => void; +} + +const ThemeContext = createContext(null); + +const DEFAULT_ACCENT_TRIPLET = hexToRgbTriplet(DEFAULT_ACCENT)!; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const systemScheme = useRNColorScheme(); + const [theme, setThemeState] = useState("dark"); + const [accent, setAccentState] = useState(DEFAULT_ACCENT); + const [fontSize, setFontSizeState] = useState("md"); + const [ready, setReady] = useState(false); + + // Load persisted preferences once on mount. + useEffect(() => { + (async () => { + try { + const [savedTheme, savedAccent, savedFont] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEYS.theme), + AsyncStorage.getItem(STORAGE_KEYS.accent), + AsyncStorage.getItem(STORAGE_KEYS.fontSize), + ]); + if (savedTheme) setThemeState(savedTheme as ThemeId); + if (savedAccent && hexToRgbTriplet(savedAccent)) + setAccentState(savedAccent); + if (savedFont) setFontSizeState(savedFont as FontSizeId); + } catch { + // best-effort; fall back to defaults + } finally { + setReady(true); + } + })(); + }, []); + + const setTheme = useCallback((t: ThemeId) => { + setThemeState(t); + AsyncStorage.setItem(STORAGE_KEYS.theme, t).catch(() => {}); + }, []); + + const setAccent = useCallback((hex: string) => { + if (!hexToRgbTriplet(hex)) return; + setAccentState(hex); + AsyncStorage.setItem(STORAGE_KEYS.accent, hex).catch(() => {}); + }, []); + + const setFontSize = useCallback((id: FontSizeId) => { + setFontSizeState(id); + AsyncStorage.setItem(STORAGE_KEYS.fontSize, id).catch(() => {}); + }, []); + + const resolvedTheme: Exclude = + theme === "system" ? (systemScheme === "light" ? "light" : "dark") : theme; + + const isDark = THEME_IS_DARK[resolvedTheme]; + const accentTriplet = hexToRgbTriplet(accent) ?? DEFAULT_ACCENT_TRIPLET; + const fontScale = + (FONT_SIZES.find((f) => f.id === fontSize)?.px ?? 14) / 14; + + // Build the CSS-variable style for the active theme + accent. + const themeStyle = useMemo(() => { + const tokens = THEME_VARS[resolvedTheme]; + const cssVars: Record = { "--accent-brand": accentTriplet }; + for (const [name, value] of Object.entries(tokens)) { + cssVars[`--${name}`] = value; + } + return vars(cssVars); + }, [resolvedTheme, accentTriplet]); + + const value = useMemo( + () => ({ + theme, + resolvedTheme, + isDark, + accent, + accentTriplet, + fontSize, + fontScale, + ready, + setTheme, + setAccent, + setFontSize, + }), + [ + theme, + resolvedTheme, + isDark, + accent, + accentTriplet, + fontSize, + fontScale, + ready, + setTheme, + setAccent, + setFontSize, + ], + ); + + // The `dark` class enables Tailwind dark: variants; the vars() style on the + // same root view supplies every theme token (and overrides the accent). + return ( + + + {children} + + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} + +/** Resolve a token name (e.g. "accent-brand", "muted-foreground") to an rgb() + * string, optionally with alpha. Useful for non-className props (icon colors, + * WebView injection, chart fills). */ +export function useThemeColor() { + const { resolvedTheme, accentTriplet } = useTheme(); + return useCallback( + (token: string, alpha = 1) => { + const triplet = + token === "accent-brand" + ? accentTriplet + : THEME_VARS[resolvedTheme][token]; + if (!triplet) return undefined; + return alpha >= 1 + ? `rgb(${triplet.split(" ").join(",")})` + : `rgba(${triplet.split(" ").join(",")},${alpha})`; + }, + [resolvedTheme, accentTriplet], + ); +} diff --git a/app/main-axios.ts b/app/main-axios.ts index b568352..518e6a6 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -696,6 +696,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { : null, terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), + ...buildProtocolFields(hostData), }; if (!submitData.enableTunnel) { @@ -727,6 +728,39 @@ export async function createSSHHost(hostData: SSHHostData): Promise { } } +/** + * Modern multi-protocol fields shared by create/update. Only included when the + * caller provides them so legacy SSH-only payloads are unchanged. SSH defaults + * to enabled when no protocol flags are specified. + */ +function buildProtocolFields(hostData: SSHHostData): Record { + const anyProtocol = + hostData.enableSsh !== undefined || + hostData.enableRdp || + hostData.enableVnc || + hostData.enableTelnet; + return { + enableSsh: anyProtocol ? Boolean(hostData.enableSsh) : true, + enableRdp: Boolean(hostData.enableRdp), + enableVnc: Boolean(hostData.enableVnc), + enableTelnet: Boolean(hostData.enableTelnet), + enableDocker: Boolean(hostData.enableDocker), + notes: hostData.notes ?? "", + rdpUser: hostData.enableRdp ? (hostData.rdpUser ?? null) : null, + rdpPassword: hostData.enableRdp ? (hostData.rdpPassword ?? null) : null, + rdpDomain: hostData.enableRdp ? (hostData.rdpDomain ?? null) : null, + rdpPort: hostData.enableRdp ? (hostData.rdpPort ?? null) : null, + vncUser: hostData.enableVnc ? (hostData.vncUser ?? null) : null, + vncPassword: hostData.enableVnc ? (hostData.vncPassword ?? null) : null, + vncPort: hostData.enableVnc ? (hostData.vncPort ?? null) : null, + telnetUser: hostData.enableTelnet ? (hostData.telnetUser ?? null) : null, + telnetPassword: hostData.enableTelnet + ? (hostData.telnetPassword ?? null) + : null, + telnetPort: hostData.enableTelnet ? (hostData.telnetPort ?? null) : null, + }; +} + export async function updateSSHHost( hostId: number, hostData: SSHHostData, @@ -762,6 +796,7 @@ export async function updateSSHHost( : null, terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), + ...buildProtocolFields(hostData), }; if (!submitData.enableTunnel) { @@ -3126,3 +3161,149 @@ export async function unlinkOIDCFromPasswordAccount( throw error; } } + +// ============================================================================ +// OPEN TABS / CROSS-DEVICE SESSIONS +// Persists open tabs per user so connections can be revived and switched +// between devices (open on desktop, continue on mobile). Mirrors the web app. +// ============================================================================ + +export interface OpenTabRecord { + id: string; + userId: string; + tabType: string; + hostId: number | null; + label: string; + tabOrder: number; + backendSessionId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface OpenTabUpsertPayload { + id: string; + tabType: string; + hostId?: number | null; + label: string; + tabOrder: number; + backendSessionId?: string | null; +} + +export interface ActiveSessionInfo { + sessionId: string; + hostId: number; + hostName: string; + tabInstanceId: string | null; + isConnected: boolean; + createdAt: number; +} + +export async function getOpenTabs(): Promise { + try { + const response = await authApi.get("/open-tabs"); + return Array.isArray(response.data) ? response.data : []; + } catch { + return []; + } +} + +export async function addOpenTab(tab: OpenTabUpsertPayload): Promise { + try { + await authApi.post("/open-tabs", tab); + } catch { + // best-effort; cross-device sync is non-critical to local use + } +} + +export async function patchOpenTab( + instanceId: string, + updates: Partial< + Pick + >, +): Promise { + try { + await authApi.patch(`/open-tabs/${instanceId}`, updates); + } catch { + // best-effort + } +} + +export async function deleteOpenTab(instanceId: string): Promise { + try { + await authApi.delete(`/open-tabs/${instanceId}`); + } catch { + // best-effort + } +} + +export async function getActiveSessions(): Promise { + try { + const response = await authApi.get("/open-tabs/active-sessions"); + return Array.isArray(response.data) ? response.data : []; + } catch { + return []; + } +} + +// ============================================================================ +// DOCKER — container management over the host's docker socket +// (Guacamole helpers getGuacamoleWebSocketUrl/getGuacamoleTokenFromHost are +// defined earlier in this file — they came in with the dev-1.4.0 remote +// desktop work.) +// ============================================================================ + +export interface DockerContainer { + id: string; + name: string; + image: string; + state: string; + status: string; +} + +export async function getDockerContainers( + hostId: number, +): Promise { + try { + const response = await sshHostApi.get(`/${hostId}/docker/containers`); + const data = response.data; + return Array.isArray(data) ? data : (data?.containers ?? []); + } catch (error) { + handleApiError(error, "list docker containers"); + throw error; + } +} + +export async function dockerContainerAction( + hostId: number, + containerId: string, + action: "start" | "stop" | "restart" | "pause" | "unpause" | "remove", +): Promise { + try { + if (action === "remove") { + await sshHostApi.delete(`/${hostId}/docker/containers/${containerId}`); + } else { + await sshHostApi.post( + `/${hostId}/docker/containers/${containerId}/${action}`, + ); + } + } catch (error) { + handleApiError(error, `docker ${action}`); + throw error; + } +} + +export async function getDockerContainerLogs( + hostId: number, + containerId: string, +): Promise { + try { + const response = await sshHostApi.get( + `/${hostId}/docker/containers/${containerId}/logs`, + ); + const data = response.data; + return typeof data === "string" ? data : (data?.logs ?? ""); + } catch (error) { + handleApiError(error, "fetch docker logs"); + throw error; + } +} diff --git a/app/tabs/dialogs/HostKeyVerificationDialog.tsx b/app/tabs/dialogs/HostKeyVerificationDialog.tsx index 21b6ba8..e7a8488 100644 --- a/app/tabs/dialogs/HostKeyVerificationDialog.tsx +++ b/app/tabs/dialogs/HostKeyVerificationDialog.tsx @@ -19,7 +19,7 @@ import { import { useOrientation } from "@/app/utils/orientation"; import { getResponsivePadding } from "@/app/utils/responsive"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import type { HostKeyData } from "./NativeWebSocketManager"; +import type { HostKeyData } from "@/app/tabs/sessions/terminal/NativeWebSocketManager"; interface HostKeyVerificationDialogProps { visible: boolean; diff --git a/app/tabs/dialogs/SSHAuthDialog.tsx b/app/tabs/dialogs/SSHAuthDialog.tsx index 1b55a08..0797090 100644 --- a/app/tabs/dialogs/SSHAuthDialog.tsx +++ b/app/tabs/dialogs/SSHAuthDialog.tsx @@ -14,7 +14,6 @@ import { ScrollView, Platform, KeyboardAvoidingView, - TouchableWithoutFeedback, } from "react-native"; import { BORDERS, diff --git a/app/tabs/dialogs/TOTPDialog.tsx b/app/tabs/dialogs/TOTPDialog.tsx index 0165c72..e5217a3 100644 --- a/app/tabs/dialogs/TOTPDialog.tsx +++ b/app/tabs/dialogs/TOTPDialog.tsx @@ -77,7 +77,7 @@ const TOTPDialogComponent: React.FC = ({ const handlePaste = useCallback(async () => { try { - const clipboardContent = await Clipboard.getString(); + const clipboardContent = await Clipboard.getStringAsync(); if (clipboardContent) { const pastedCode = isPasswordPrompt ? clipboardContent diff --git a/app/tabs/hosts/HostActionSheet.tsx b/app/tabs/hosts/HostActionSheet.tsx new file mode 100644 index 0000000..f1afb67 --- /dev/null +++ b/app/tabs/hosts/HostActionSheet.tsx @@ -0,0 +1,166 @@ +import { View, ScrollView } from "react-native"; +import { + SquareTerminal, + FolderOpen, + Activity, + Network, + Container, + Monitor, + Pencil, + Pin, + PinOff, + Trash2, +} from "lucide-react-native"; +import { SSHHost } from "@/types"; +import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; +import type { SessionType } from "@/app/contexts/TerminalSessionsContext"; +import { BottomSheet, SheetRow, Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; + +function parseStatsConfig(host: SSHHost): StatsConfig { + try { + return host.statsConfig ? JSON.parse(host.statsConfig) : DEFAULT_STATS_CONFIG; + } catch { + return DEFAULT_STATS_CONFIG; + } +} + +export function HostActionSheet({ + host, + status, + visible, + onClose, + onEdit, + onTogglePin, + onDelete, +}: { + host: SSHHost | null; + status: "online" | "offline" | "unknown"; + visible: boolean; + onClose: () => void; + onEdit: (host: SSHHost) => void; + onTogglePin: (host: SSHHost) => void; + onDelete: (host: SSHHost) => void; +}) { + const { navigateToSessions } = useTerminalSessions(); + const color = useThemeColor(); + const iconColor = color("foreground") ?? "#fafafa"; + const accent = color("accent-brand") ?? "#f59145"; + + if (!host) return null; + + const open = (type: SessionType) => { + navigateToSessions(host, type); + onClose(); + }; + + const statsEnabled = parseStatsConfig(host).metricsEnabled; + const hasRemoteDesktop = + host.enableRdp || host.enableVnc || host.enableTelnet; + + const dotColor = + status === "online" ? "#22c55e" : status === "offline" ? "#ef4444" : "#9ca3af"; + + return ( + + {/* Header */} + + + + + {host.name} + + + {host.username ? `${host.username}@` : ""} + {host.ip} + {host.port ? `:${host.port}` : ""} + + + + + + {host.enableTerminal !== false ? ( + } + label="Terminal" + onPress={() => open("terminal")} + /> + ) : null} + {host.enableFileManager ? ( + } + label="File Manager" + onPress={() => open("filemanager")} + /> + ) : null} + {statsEnabled ? ( + } + label="Server Stats" + onPress={() => open("stats")} + /> + ) : null} + {host.enableTunnel && + host.tunnelConnections && + host.tunnelConnections.length > 0 ? ( + } + label="Tunnels" + onPress={() => open("tunnel")} + /> + ) : null} + {host.enableDocker ? ( + } + label="Docker" + onPress={() => open("docker")} + /> + ) : null} + {hasRemoteDesktop ? ( + } + label="Remote Desktop" + onPress={() => open("remoteDesktop")} + /> + ) : null} + + {/* Management actions */} + } + label="Edit Host" + onPress={() => { + onEdit(host); + onClose(); + }} + /> + + ) : ( + + ) + } + label={host.pin ? "Unpin" : "Pin"} + onPress={() => { + onTogglePin(host); + onClose(); + }} + /> + } + label="Delete Host" + destructive + onPress={() => { + onDelete(host); + onClose(); + }} + /> + + + ); +} diff --git a/app/tabs/hosts/HostForm.tsx b/app/tabs/hosts/HostForm.tsx new file mode 100644 index 0000000..68830b6 --- /dev/null +++ b/app/tabs/hosts/HostForm.tsx @@ -0,0 +1,484 @@ +import { useEffect, useState } from "react"; +import { Modal, View, ScrollView, Pressable } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { X } from "lucide-react-native"; +import { SSHHost, SSHHostData, Credential } from "@/types"; +import { + createSSHHost, + updateSSHHost, + getSSHHostWithCredentials, + getCredentials, +} from "@/app/main-axios"; +import { + Text, + Input, + Button, + Label, + FakeSwitch, + SettingRow, + AccordionSection, + SegmentedControl, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; + +type AuthType = "password" | "key" | "credential" | "none"; + +interface FormState { + name: string; + ip: string; + port: string; + username: string; + folder: string; + tags: string; + pin: boolean; + authType: AuthType; + password: string; + key: string; + keyPassword: string; + credentialId?: number; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + enableDocker: boolean; + defaultPath: string; + notes: string; + enableRdp: boolean; + enableVnc: boolean; + enableTelnet: boolean; +} + +const EMPTY: FormState = { + name: "", + ip: "", + port: "22", + username: "", + folder: "", + tags: "", + pin: false, + authType: "password", + password: "", + key: "", + keyPassword: "", + credentialId: undefined, + enableTerminal: true, + enableTunnel: false, + enableFileManager: true, + enableDocker: false, + defaultPath: "/", + notes: "", + enableRdp: false, + enableVnc: false, + enableTelnet: false, +}; + +export default function HostForm({ + visible, + host, + onClose, + onSaved, +}: { + visible: boolean; + host: SSHHost | null; + onClose: () => void; + onSaved: () => void; +}) { + const insets = useSafeAreaInsets(); + const color = useThemeColor(); + const [form, setForm] = useState(EMPTY); + const [credentials, setCredentials] = useState([]); + const [saving, setSaving] = useState(false); + const isEdit = !!host; + + const set = (k: K, v: FormState[K]) => + setForm((f) => ({ ...f, [k]: v })); + + // Load credentials list for the credential picker. + useEffect(() => { + if (!visible) return; + getCredentials() + .then((res) => { + const list = Array.isArray(res) ? res : (res?.credentials ?? []); + setCredentials(list); + }) + .catch(() => {}); + }, [visible]); + + // Populate the form when opening (prefill secrets on edit). + useEffect(() => { + if (!visible) return; + if (!host) { + setForm(EMPTY); + return; + } + // Start from the known fields, then enrich with resolved secrets. + setForm({ + ...EMPTY, + name: host.name ?? "", + ip: host.ip ?? "", + port: String(host.port ?? host.sshPort ?? 22), + username: host.username ?? "", + folder: host.folder ?? "", + tags: (host.tags ?? []).join(", "), + pin: !!host.pin, + authType: host.authType ?? "password", + credentialId: host.credentialId, + enableTerminal: host.enableTerminal !== false, + enableTunnel: !!host.enableTunnel, + enableFileManager: !!host.enableFileManager, + enableDocker: !!host.enableDocker, + defaultPath: host.defaultPath || "/", + notes: host.notes ?? "", + enableRdp: !!host.enableRdp, + enableVnc: !!host.enableVnc, + enableTelnet: !!host.enableTelnet, + }); + getSSHHostWithCredentials(host.id) + .then((full) => { + if (!full) return; + setForm((f) => ({ + ...f, + password: full.password ?? "", + key: full.key ?? "", + keyPassword: full.keyPassword ?? "", + })); + }) + .catch(() => {}); + }, [visible, host]); + + const handleSave = async () => { + if (!form.ip.trim()) { + toast.error("Host address is required"); + return; + } + if (!form.username.trim() && form.authType !== "none") { + toast.error("Username is required"); + return; + } + + const payload: SSHHostData = { + name: form.name.trim() || form.ip.trim(), + ip: form.ip.trim(), + port: parseInt(form.port, 10) || 22, + username: form.username.trim(), + folder: form.folder.trim(), + tags: form.tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + pin: form.pin, + authType: form.authType, + password: form.authType === "password" ? form.password : undefined, + key: form.authType === "key" ? (form.key as any) : undefined, + keyPassword: form.authType === "key" ? form.keyPassword : undefined, + credentialId: + form.authType === "credential" ? form.credentialId : undefined, + enableTerminal: form.enableTerminal, + enableTunnel: form.enableTunnel, + enableFileManager: form.enableFileManager, + enableDocker: form.enableDocker, + defaultPath: form.defaultPath, + notes: form.notes, + enableSsh: true, + enableRdp: form.enableRdp, + enableVnc: form.enableVnc, + enableTelnet: form.enableTelnet, + }; + + setSaving(true); + try { + if (isEdit && host) { + await updateSSHHost(host.id, payload); + toast.success("Host updated"); + } else { + await createSSHHost(payload); + toast.success("Host created"); + } + onSaved(); + } catch (e: any) { + toast.error(e?.message || "Failed to save host"); + } finally { + setSaving(false); + } + }; + + return ( + + + {/* Header */} + + + + + Cancel + + + + {isEdit ? "Edit Host" : "New Host"} + + + + + + {/* Connection */} + + + set("name", v)} placeholder="My Server" /> + + + + + set("ip", v)} + placeholder="192.168.1.10" + autoCapitalize="none" + autoCorrect={false} + /> + + + + + set("port", v.replace(/\D/g, ""))} + keyboardType="number-pad" + placeholder="22" + /> + + + + + set("username", v)} + placeholder="root" + autoCapitalize="none" + autoCorrect={false} + /> + + + + {/* Authentication */} + + + + value={form.authType} + onChange={(v) => set("authType", v)} + options={[ + { id: "password", label: "Pass" }, + { id: "key", label: "Key" }, + { id: "credential", label: "Cred" }, + { id: "none", label: "None" }, + ]} + /> + + {form.authType === "password" ? ( + + set("password", v)} + secureTextEntry + placeholder={isEdit ? "•••••• (unchanged if blank)" : "Password"} + autoCapitalize="none" + /> + + ) : null} + + {form.authType === "key" ? ( + <> + + set("key", v)} + multiline + placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" + autoCapitalize="none" + autoCorrect={false} + style={{ minHeight: 90, textAlignVertical: "top" }} + /> + + + set("keyPassword", v)} + secureTextEntry + placeholder="Passphrase" + autoCapitalize="none" + /> + + + ) : null} + + {form.authType === "credential" ? ( + + {credentials.length === 0 ? ( + + No saved credentials. Create one in the Tools tab. + + ) : ( + + {credentials.map((c) => { + const selected = form.credentialId === c.id; + return ( + set("credentialId", c.id)} + className={`px-3 py-2.5 border ${selected ? "border-accent-brand/40 bg-accent-brand/10" : "border-border bg-card"}`} + > + + {c.name} + + {c.username ? ( + + {c.username} + + ) : null} + + ); + })} + + )} + + ) : null} + + + + {/* Organization */} + + + + set("folder", v)} + placeholder="Production" + /> + + + set("tags", v)} + placeholder="web, nginx" + autoCapitalize="none" + /> + + + set("pin", v)} /> + + + + + {/* Features */} + + + + set("enableTerminal", v)} + /> + + + set("enableFileManager", v)} + /> + + {form.enableFileManager ? ( + + set("defaultPath", v)} + placeholder="/" + autoCapitalize="none" + /> + + ) : null} + + set("enableTunnel", v)} + /> + + + set("enableDocker", v)} + /> + + + + + {/* Protocols (remote desktop) */} + + + + set("enableRdp", v)} + /> + + + set("enableVnc", v)} + /> + + + set("enableTelnet", v)} + /> + + + + + {/* Notes */} + + + set("notes", v)} + multiline + placeholder="Notes about this host…" + style={{ minHeight: 70, textAlignVertical: "top" }} + /> + + + + + + ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} diff --git a/app/tabs/hosts/Hosts.tsx b/app/tabs/hosts/Hosts.tsx index 432eb48..21481f9 100644 --- a/app/tabs/hosts/Hosts.tsx +++ b/app/tabs/hosts/Hosts.tsx @@ -1,44 +1,68 @@ import { ScrollView, - Text, - TextInput, View, ActivityIndicator, - Alert, - TouchableOpacity, RefreshControl, + Pressable, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useState, useCallback, useRef } from "react"; import { useFocusEffect } from "@react-navigation/native"; -import { RefreshCw } from "lucide-react-native"; +import { + RefreshCw, + Search, + X, + ArrowUpDown, + Check, + Zap, +} from "lucide-react-native"; import Folder from "@/app/tabs/hosts/navigation/Folder"; +import { HostActionSheet } from "@/app/tabs/hosts/HostActionSheet"; +import HostForm from "@/app/tabs/hosts/HostForm"; +import { QuickConnect } from "@/app/tabs/hosts/QuickConnect"; import { getSSHHosts, getFoldersWithStats, getAllServerStatuses, initializeServerConfig, getCurrentServerUrl, + deleteSSHHost, + updateSSHHost, } from "@/app/main-axios"; import { SSHHost, ServerStatus } from "@/types"; -import { useOrientation } from "@/app/utils/orientation"; -import { getResponsivePadding, getColumnCount } from "@/app/utils/responsive"; +import { Screen } from "@/app/components/Screen"; +import { + Text, + Input, + Button, + BottomSheet, + SheetRow, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; interface FolderData { name: string; + color?: string; hosts: SSHHost[]; - stats?: { - totalHosts: number; - hostsByType: { - type: string; - count: number; - }[]; - }; } +type SortKey = + | "default" + | "name-asc" + | "name-desc" + | "status-online" + | "pinned"; + +const SORT_OPTIONS: { id: SortKey; label: string }[] = [ + { id: "default", label: "Default" }, + { id: "name-asc", label: "Name (A–Z)" }, + { id: "name-desc", label: "Name (Z–A)" }, + { id: "status-online", label: "Online first" }, + { id: "pinned", label: "Pinned first" }, +]; + export default function Hosts() { - const insets = useSafeAreaInsets(); - const { width, isLandscape } = useOrientation(); + const color = useThemeColor(); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -46,32 +70,27 @@ export default function Hosts() { const [serverStatuses, setServerStatuses] = useState< Record >({}); - const isRefreshingRef = useRef(false); + const [sortKey, setSortKey] = useState("default"); + const [showSort, setShowSort] = useState(false); - const padding = getResponsivePadding(isLandscape); - const columnCount = getColumnCount(width, isLandscape, 400); + // Action sheet + form state + const [sheetHost, setSheetHost] = useState(null); + const [formHost, setFormHost] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [quickConnectOpen, setQuickConnectOpen] = useState(false); + + const isRefreshingRef = useRef(false); const fetchData = useCallback(async (isRefresh = false) => { if (isRefreshingRef.current) return; - try { isRefreshingRef.current = true; - - if (isRefresh) { - setRefreshing(true); - } else { - setLoading(true); - } + if (isRefresh) setRefreshing(true); + else setLoading(true); await initializeServerConfig(); - - const currentServerUrl = getCurrentServerUrl(); - - if (!currentServerUrl) { - Alert.alert( - "No Server Configured", - "Please configure a server first in the settings.", - ); + if (!getCurrentServerUrl()) { + toast.error("No server configured. Set one up in Settings."); return; } @@ -80,70 +99,54 @@ export default function Hosts() { getAllServerStatuses(), ]); - if (hostsResult.status !== "fulfilled") { - throw hostsResult.reason; - } + if (hostsResult.status !== "fulfilled") throw hostsResult.reason; - const hostsRaw = hostsResult.value; - const hosts: SSHHost[] = Array.isArray(hostsRaw) - ? hostsRaw - : Array.isArray((hostsRaw as any)?.hosts) - ? (hostsRaw as any).hosts + const raw = hostsResult.value as any; + const hosts: SSHHost[] = Array.isArray(raw) + ? raw + : Array.isArray(raw?.hosts) + ? raw.hosts : []; const statuses = statusesResult.status === "fulfilled" ? statusesResult.value : {}; - let foldersData = null; + let foldersData: any = null; try { foldersData = await getFoldersWithStats(); - } catch (error) {} + } catch { + // folders are optional + } const folderMap = new Map(); - - if (foldersData && Array.isArray(foldersData)) { - foldersData.forEach((folder: any) => { - folderMap.set(folder.name, { - name: folder.name, - hosts: [], - stats: folder.stats, - }); - }); + if (Array.isArray(foldersData)) { + foldersData.forEach((f: any) => + folderMap.set(f.name, { name: f.name, color: f.color, hosts: [] }), + ); } - hosts.forEach((host: SSHHost) => { + hosts.forEach((host) => { const folderName = host.folder || "No Folder"; - if (!folderMap.has(folderName)) { - folderMap.set(folderName, { - name: folderName, - hosts: [], - stats: { totalHosts: 0, hostsByType: [] }, - }); - } + if (!folderMap.has(folderName)) + folderMap.set(folderName, { name: folderName, hosts: [] }); folderMap.get(folderName)!.hosts.push(host); }); - const foldersArray = Array.from(folderMap.values()).sort((a, b) => { - if (a.name === "No Folder") return 1; - if (b.name === "No Folder") return -1; - return a.name.localeCompare(b.name); - }); + const arr = Array.from(folderMap.values()) + .filter((f) => f.hosts.length > 0) + .sort((a, b) => { + if (a.name === "No Folder") return 1; + if (b.name === "No Folder") return -1; + return a.name.localeCompare(b.name); + }); - setFolders(foldersArray); + setFolders(arr); setServerStatuses(statuses); } catch (error: any) { - console.error("[Hosts] Error loading hosts:", error); - - const isAuthError = + const isAuth = error?.response?.status === 401 || - error?.status === 401 || error?.message?.includes("Authentication required"); - - if (isAuthError) { - } else { - const errorMessage = - error?.message || - "Failed to load hosts. Please check your connection and try again."; - Alert.alert("Error Loading Hosts", errorMessage); + if (!isAuth) { + toast.error(error?.message || "Failed to load hosts."); } } finally { setLoading(false); @@ -153,9 +156,7 @@ export default function Hosts() { }, []); const handleRefresh = useCallback(() => { - if (!isRefreshingRef.current) { - fetchData(true); - } + if (!isRefreshingRef.current) fetchData(true); }, [fetchData]); useFocusEffect( @@ -164,108 +165,231 @@ export default function Hosts() { }, [fetchData]), ); + const getHostStatus = ( + hostId: number, + ): "online" | "offline" | "unknown" => { + return serverStatuses[hostId]?.status ?? "unknown"; + }; + + const sortHosts = (hosts: SSHHost[]): SSHHost[] => { + if (sortKey === "default") return hosts; + const copy = [...hosts]; + copy.sort((a, b) => { + switch (sortKey) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "status-online": + return ( + (getHostStatus(b.id) === "online" ? 1 : 0) - + (getHostStatus(a.id) === "online" ? 1 : 0) + ); + case "pinned": + return (b.pin ? 1 : 0) - (a.pin ? 1 : 0); + default: + return 0; + } + }); + return copy; + }; + + const q = searchQuery.trim().toLowerCase(); const filteredFolders = folders .map((folder) => ({ ...folder, - hosts: folder.hosts.filter( - (host) => - host.name.toLowerCase().includes(searchQuery.toLowerCase()) || - host.ip.toLowerCase().includes(searchQuery.toLowerCase()) || - host.username.toLowerCase().includes(searchQuery.toLowerCase()), + hosts: sortHosts( + folder.hosts.filter( + (h) => + !q || + h.name.toLowerCase().includes(q) || + h.ip.toLowerCase().includes(q) || + (h.username ?? "").toLowerCase().includes(q) || + (h.tags ?? []).some((t) => t.toLowerCase().includes(q)), + ), ), })) - .filter((folder) => folder.hosts.length > 0 || searchQuery === ""); + .filter((folder) => folder.hosts.length > 0); - const getHostStatus = (hostId: number): "online" | "offline" | "unknown" => { - const status = serverStatuses[hostId]; - if (!status) return "unknown"; - return status.status; + const handleTogglePin = async (host: SSHHost) => { + try { + await updateSSHHost(host.id, { ...(host as any), pin: !host.pin }); + toast.success(host.pin ? "Unpinned" : "Pinned"); + fetchData(true); + } catch (e: any) { + toast.error(e?.message || "Failed to update host"); + } }; - if (loading) { - return ( - - - Loading hosts... - - ); - } + const handleDelete = async (host: SSHHost) => { + try { + await deleteSSHHost(host.id); + toast.success(`Deleted ${host.name}`); + fetchData(true); + } catch (e: any) { + toast.error(e?.message || "Failed to delete host"); + } + }; + + const openEdit = (host: SSHHost) => { + setFormHost(host); + setFormOpen(true); + }; return ( - - - - - Hosts - - + + + + } + > + + + + + + + + + setPort(v.replace(/\D/g, ""))} + keyboardType="number-pad" + /> + + + + + + + + value={authType} + onChange={setAuthType} + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "Key" }, + ]} + /> + {authType === "password" ? ( + + + + + ) : ( + + + + + )} + + + ); +} diff --git a/app/tabs/hosts/navigation/Folder.tsx b/app/tabs/hosts/navigation/Folder.tsx index f47802e..1e8255a 100644 --- a/app/tabs/hosts/navigation/Folder.tsx +++ b/app/tabs/hosts/navigation/Folder.tsx @@ -1,87 +1,80 @@ -import { View, Text, TouchableOpacity, Animated } from "react-native"; -import Host from "@/app/tabs/hosts/navigation/Host"; -import { ChevronDown } from "lucide-react-native"; -import { useState, useRef, useEffect } from "react"; +import { useState } from "react"; +import { View, Pressable } from "react-native"; +import { ChevronRight, Folder as FolderIcon } from "lucide-react-native"; import { SSHHost } from "@/types"; +import Host from "@/app/tabs/hosts/navigation/Host"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface FolderProps { name: string; hosts: SSHHost[]; + color?: string; + defaultExpanded?: boolean; + showTags?: boolean; getHostStatus: (hostId: number) => "online" | "offline" | "unknown"; + onHostPress: (host: SSHHost) => void; } -export default function Folder({ name, hosts, getHostStatus }: FolderProps) { - const [isExpanded, setIsExpanded] = useState(true); - const rotateValue = useRef(new Animated.Value(0)).current; - - const toggleExpanded = () => { - setIsExpanded(!isExpanded); - }; - - useEffect(() => { - Animated.timing(rotateValue, { - toValue: isExpanded ? 0 : 1, - duration: 200, - useNativeDriver: true, - }).start(); - }, [isExpanded, rotateValue]); - - const rotate = rotateValue.interpolate({ - inputRange: [0, 1], - outputRange: ["0deg", "180deg"], - }); +export default function Folder({ + name, + hosts, + color: folderColor, + defaultExpanded = true, + showTags = true, + getHostStatus, + onHostPress, +}: FolderProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const resolve = useThemeColor(); + const muted = resolve("muted-foreground"); return ( - - + setExpanded((e) => !e)} + className="flex-row items-center gap-2 px-2 py-2" > - - - - {name} - - - {hosts.length} host{hosts.length !== 1 ? "s" : ""} - - - - - - - - - - {isExpanded && ( - + + + + + {name} + + {hosts.length} + + + {expanded ? ( + {hosts.length === 0 ? ( - - - No hosts in this folder - - + + No hosts in this folder + ) : ( - hosts.map((host, index) => ( - ( + - - + host={host} + status={getHostStatus(host.id)} + showTags={showTags} + onPress={onHostPress} + /> )) )} - )} + ) : null} ); } diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 94d868c..b0ac61f 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -1,505 +1,98 @@ -import { - View, - Text, - TouchableOpacity, - Modal, - TouchableWithoutFeedback, - Animated, - Easing, - ScrollView, -} from "react-native"; -import { - Terminal, - FolderOpen, - MoreVertical, - X, - Activity, - Monitor, -} from "lucide-react-native"; +import { View, Pressable } from "react-native"; +import { Pin } from "lucide-react-native"; 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"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface HostProps { host: SSHHost; status: "online" | "offline" | "unknown"; - isLast?: boolean; + showTags?: boolean; + onPress: (host: SSHHost) => void; } -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 = - status === "online" ? "UP" : status === "offline" ? "DOWN" : "UNK"; - const connectionType = (host.connectionType || "ssh").toLowerCase(); - const isRemoteDesktopHost = ["rdp", "vnc", "telnet"].includes(connectionType); - const remoteDesktopLabel = - connectionType === "vnc" - ? "Open VNC Session" - : connectionType === "telnet" - ? "Open Telnet Session" - : "Open RDP Session"; +const STATUS_COLOR: Record = { + online: "#22c55e", + offline: "#ef4444", + unknown: "#9ca3af", +}; - 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": - return "bg-green-500"; - case "offline": - return "bg-red-500"; - default: - return "bg-gray-500"; - } - }; - - const getStatusPalette = () => { - switch (status) { - case "online": - return { - main: "#22C55E", - border: "#16A34A", - glow: "rgba(34,197,94,0.45)", - }; - case "offline": - return { - main: "#EF4444", - border: "#DC2626", - glow: "rgba(239,68,68,0.45)", - }; - default: - return { - main: "#9CA3AF", - border: "#6B7280", - glow: "rgba(156,163,175,0.35)", - }; - } - }; - - const rippleAnim = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (status === "online") { - rippleAnim.setValue(0); - const loop = Animated.loop( - Animated.timing(rippleAnim, { - toValue: 1, - duration: 1500, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - ); - loop.start(); - return () => { - rippleAnim.stopAnimation(); - }; - } else { - rippleAnim.stopAnimation(); - rippleAnim.setValue(0); - } - }, [status, rippleAnim]); - - const handleHostPress = () => { - setShowContextMenu(true); - }; - - const handleTerminalPress = () => { - navigateToSessions(host, "terminal"); - setShowContextMenu(false); - }; - - const handleRemoteDesktopPress = () => { - navigateToSessions(host, "remoteDesktop"); - setShowContextMenu(false); - }; - - const handleStatsPress = () => { - navigateToSessions(host, "stats"); - setShowContextMenu(false); - }; - - const handleFileManagerPress = () => { - navigateToSessions(host, "filemanager"); - setShowContextMenu(false); - }; +/** Compact protocol/feature tag (e.g. RDP, VNC, Docker) shown beside a host. */ +function FeatureChip({ label }: { label: string }) { + return ( + + + {label} + + + ); +} - const handleCloseContextMenu = () => { - setShowContextMenu(false); - }; +export default function Host({ host, status, showTags = true, onPress }: HostProps) { + const color = useThemeColor(); - const closeContextMenu = () => { - setShowContextMenu(false); - }; + const protocols: string[] = []; + if (host.enableRdp) protocols.push("RDP"); + if (host.enableVnc) protocols.push("VNC"); + if (host.enableTelnet) protocols.push("TEL"); + if (host.enableDocker) protocols.push("DKR"); return ( - <> - - - - - - {statusLabel} - - - - - {status === "online" && ( - - )} - - - - - - - - {host.name} - - - - {host.ip} - {host.username ? ` • ${host.username}` : ""} - - - onPress(host)} + className="flex-row items-center gap-3 px-3 py-3 bg-card border border-border active:bg-muted/40" + > + {/* Status dot */} + + + {/* Name + address */} + + + - - + {host.name} + + {host.pin ? ( + + ) : null} + {protocols.map((p) => ( + + ))} - - {host.tags && host.tags.length > 0 && ( - setTagsContainerWidth(e.nativeEvent.layout.width)} - > - - {(() => { - const chips: any[] = []; - if (!tagsContainerWidth) return null; - const horizontalGap = 6; - const basePadding = 16; - const borderAllowance = 6; - const avgCharPx = 7; - let used = 0; - let shown = 0; - for (let i = 0; i < host.tags.length; i++) { - const tag = String(host.tags[i]); - const tagWidth = - basePadding + - borderAllowance + - Math.ceil(tag.length * avgCharPx); - const remainingCount = host.tags.length - (shown + 1); - const moreLabel = - remainingCount > 0 ? `+${remainingCount} more` : ""; - const moreWidth = - remainingCount > 0 - ? basePadding + - borderAllowance + - Math.ceil(moreLabel.length * avgCharPx) - : 0; - const sep = shown > 0 ? horizontalGap : 0; - if ( - used + - sep + - tagWidth + - (remainingCount > 0 ? horizontalGap + moreWidth : 0) <= - tagsContainerWidth - ) { - used += sep + tagWidth; - chips.push( - - - {tag} - - , - ); - shown += 1; - } else { - break; - } - } - const hidden = host.tags.length - shown; - if (hidden > 0) { - const label = `+${hidden} more`; - chips.push( - - - {label} - - , - ); - } else if (chips.length > 0) { - const last = chips.pop(); - if (last) { - chips.push( - - {(last as any).props.children} - , - ); - } - } - return chips; - })()} - - - )} - - - - - - {}}> + + {host.username ? `${host.username}@` : ""} + {host.ip} + {host.port ? `:${host.port}` : ""} + + + {showTags && host.tags && host.tags.length > 0 ? ( + + {host.tags.slice(0, 4).map((tag, i) => ( - - - - - {host.name} - - - - - - - - - - {isRemoteDesktopHost && ( - - - - - {remoteDesktopLabel} - - - {host.ip}:{host.port} - - - - )} - - {host.enableTerminal && !isRemoteDesktopHost && ( - - - - - Open SSH Terminal - - - {host.ip} - {host.username ? ` • ${host.username}` : ""} - - - - )} - - {parsedStatsConfig.metricsEnabled && ( - - - - - View Server Stats - - - Monitor CPU, memory, and disk usage - - - - )} - - {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 - - - + + {tag} + + + ))} + {host.tags.length > 4 ? ( + + + +{host.tags.length - 4} + - + ) : null} - - - + ) : null} + + ); } - -export default Host; diff --git a/app/tabs/sessions/ConnectionsPanel.tsx b/app/tabs/sessions/ConnectionsPanel.tsx new file mode 100644 index 0000000..a33b2b7 --- /dev/null +++ b/app/tabs/sessions/ConnectionsPanel.tsx @@ -0,0 +1,351 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { View, ScrollView, Pressable, RefreshControl } from "react-native"; +import { + Plug, + SquareTerminal, + FolderOpen, + Activity, + Network, + Container, + Monitor, + ExternalLink, + X, +} from "lucide-react-native"; +import { + getActiveSessions, + type ActiveSessionInfo, + type OpenTabRecord, +} from "@/app/main-axios"; +import { + useTerminalSessions, + type SessionType, +} from "@/app/contexts/TerminalSessionsContext"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { SSHHost } from "@/types"; +import { + getSSHHosts, + deleteOpenTab, +} from "@/app/main-axios"; + +const TYPE_LABELS: Record = { + terminal: "SSH", + files: "Files", + filemanager: "Files", + stats: "Stats", + tunnel: "Tunnel", + docker: "Docker", + rdp: "RDP", + vnc: "VNC", + telnet: "Telnet", + remoteDesktop: "Remote", +}; + +function tabIcon(type: string, color: string) { + const size = 16; + switch (type) { + case "files": + case "filemanager": + return ; + case "stats": + return ; + case "tunnel": + return ; + case "docker": + return ; + case "rdp": + case "vnc": + case "telnet": + case "remoteDesktop": + return ; + default: + return ; + } +} + +/** Map an open-tabs record tabType back to our local SessionType. */ +function toSessionType(tabType: string): SessionType { + switch (tabType) { + case "files": + return "filemanager"; + case "rdp": + case "vnc": + case "telnet": + return "remoteDesktop"; + case "stats": + case "tunnel": + case "docker": + return tabType as SessionType; + default: + return "terminal"; + } +} + +function SectionHeader({ label, count }: { label: string; count: number }) { + return ( + + + {label} + + + {count} + + + ); +} + +function ConnectionRow({ + isActive, + isLive, + tabType, + name, + subLabel, + onSwitch, + onClose, + reconnectHint, +}: { + isActive?: boolean; + isLive: boolean; + tabType: string; + name: string; + subLabel: string; + onSwitch: () => void; + onClose: () => void; + reconnectHint?: boolean; +}) { + const color = useThemeColor(); + const iconColor = + (isActive ? color("accent-brand") : color("muted-foreground")) ?? "#a4a4a4"; + + return ( + + + {tabIcon(tabType, iconColor)} + + + + + + {name} + + + + {TYPE_LABELS[tabType] ?? tabType} + + + + + {subLabel} + + + + {reconnectHint ? ( + + ) : null} + { + e.stopPropagation(); + onClose(); + }} + hitSlop={8} + className="w-6 h-6 items-center justify-center" + > + + + + + ); +} + +/** + * ConnectionsPanel — the central connections surface. Shows tabs open on this + * device (Open) plus tabs/sessions that exist server-side but aren't open here + * (Background — e.g. opened on desktop, or backgrounded). Reviving a background + * tab reconnects to its live backend session when one exists, enabling + * cross-device tab switching. + */ +export function ConnectionsPanel() { + const color = useThemeColor(); + const { + sessions, + activeSessionId, + backgroundTabRecords, + refreshBackgroundTabs, + setActiveSession, + removeSession, + addSession, + navigateToSessions, + forgetBackgroundTab, + } = useTerminalSessions(); + + const [activeSessions, setActiveSessions] = useState([]); + const [hosts, setHosts] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const pollRef = useRef | null>(null); + + const refresh = useCallback(async () => { + const [live] = await Promise.all([ + getActiveSessions(), + refreshBackgroundTabs(), + ]); + setActiveSessions(live); + }, [refreshBackgroundTabs]); + + useEffect(() => { + getSSHHosts() + .then((res: any) => { + const list = Array.isArray(res) ? res : (res?.hosts ?? []); + setHosts(list); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + refresh(); + pollRef.current = setInterval(refresh, 5000); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [refresh]); + + const sessionByInstance = new Map( + activeSessions.map((s) => [s.tabInstanceId, s]), + ); + + // Background = server records whose instanceId isn't an open tab here. + const openInstanceIds = new Set(sessions.map((s) => s.instanceId)); + const backgroundTabs = backgroundTabRecords.filter( + (r) => !openInstanceIds.has(r.id), + ); + + const reviveBackground = (record: OpenTabRecord) => { + const host = hosts.find((h) => h.id === record.hostId); + if (!host) return; + const live = sessionByInstance.get(record.id); + addSession(host, toSessionType(record.tabType), { + instanceId: record.id, + restoredSessionId: live?.sessionId ?? record.backendSessionId ?? null, + }); + navigateToSessions(); + }; + + const hasAny = sessions.length > 0 || backgroundTabs.length > 0; + + if (!hasAny) { + return ( + + + + + + No active connections + + + Connect to a host to start a session. Tabs you open on other devices + will appear here too. + + + ); + } + + return ( + { + setRefreshing(true); + await refresh(); + setRefreshing(false); + }} + tintColor={color("accent-brand")} + /> + } + > + {sessions.length > 0 ? ( + + + {sessions.map((s) => { + const live = sessionByInstance.get(s.instanceId); + const isLive = s.type === "terminal" ? (live?.isConnected ?? false) : true; + return ( + { + setActiveSession(s.id); + navigateToSessions(); + }} + onClose={() => removeSession(s.id)} + /> + ); + })} + + ) : null} + + {backgroundTabs.length > 0 ? ( + 0 ? "mt-2" : ""}> + + + + Sessions from other devices or backgrounded tabs. Tap to revive. + + + {backgroundTabs.map((r) => { + const host = hosts.find((h) => h.id === r.hostId); + const live = sessionByInstance.get(r.id); + return ( + reviveBackground(r)} + onClose={async () => { + await deleteOpenTab(r.id); + forgetBackgroundTab(r.id); + }} + /> + ); + })} + + ) : null} + + ); +} diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index bdbdd46..9e9a912 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { View, - Text, Keyboard, Platform, TextInput, @@ -35,12 +34,15 @@ import { TunnelManagerHandle, } from "@/app/tabs/sessions/tunnel/TunnelManager"; import { RemoteDesktop } from "@/app/tabs/sessions/remote-desktop/RemoteDesktop"; +import { Docker } from "@/app/tabs/sessions/docker/Docker"; +import { ConnectionsPanel } from "@/app/tabs/sessions/ConnectionsPanel"; +import { Screen } from "@/app/components/Screen"; import TabBar from "@/app/tabs/sessions/navigation/TabBar"; import BottomToolbar from "@/app/tabs/sessions/terminal/keyboard/BottomToolbar"; import KeyboardBar from "@/app/tabs/sessions/terminal/keyboard/KeyboardBar"; import { useOrientation } from "@/app/utils/orientation"; import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; -import { BACKGROUNDS, BORDER_COLORS } from "@/app/constants/designTokens"; +import { BACKGROUNDS } from "@/app/constants/designTokens"; import { addKeyCommandListener } from "@/modules/hardware-keyboard"; type ActiveModifiers = { @@ -59,6 +61,7 @@ export default function Sessions() { activeSessionId, setActiveSession, removeSession, + setBackendSessionId, isCustomKeyboardVisible, toggleCustomKeyboard, lastKeyboardHeight, @@ -539,7 +542,7 @@ export default function Sessions() { ? activeTerminalBgColor : activeSession?.type === "filemanager" ? BACKGROUNDS.HEADER - : "#18181b", + : BACKGROUNDS.DARK, }} > + setBackendSessionId(session.id, backendId) + } onClose={() => handleTabClose(session.id)} onBackgroundColorChange={(color) => { setTerminalBackgroundColors((prev) => ({ @@ -637,6 +645,14 @@ export default function Sessions() { onClose={() => handleTabClose(session.id)} /> ); + } else if (session.type === "docker") { + return ( + + ); } return null; })} @@ -646,86 +662,16 @@ export default function Sessions() { - - - No Active Terminal Sessions - - - Connect to a host from the Hosts tab to start a session - - { - handleAddSession(); - }} - > - - Go to Hosts - - - + + + )} diff --git a/app/tabs/sessions/docker/Docker.tsx b/app/tabs/sessions/docker/Docker.tsx new file mode 100644 index 0000000..e49bbc1 --- /dev/null +++ b/app/tabs/sessions/docker/Docker.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useState } from "react"; +import { + View, + ScrollView, + Pressable, + RefreshControl, + ActivityIndicator, + Modal, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + Container, + Play, + Square, + RotateCcw, + Trash2, + FileText, + X, +} from "lucide-react-native"; +import { SSHHost } from "@/types"; +import { + getDockerContainers, + dockerContainerAction, + getDockerContainerLogs, + type DockerContainer, +} from "@/app/main-axios"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { toast } from "@/app/utils/toast"; + +interface DockerProps { + host: SSHHost; + isVisible: boolean; +} + +export function Docker({ host, isVisible }: DockerProps) { + const color = useThemeColor(); + const insets = useSafeAreaInsets(); + const [containers, setContainers] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [busy, setBusy] = useState(null); + const [logs, setLogs] = useState<{ name: string; text: string } | null>(null); + + const load = useCallback( + async (spinner = true) => { + if (spinner) setLoading(true); + try { + const list = await getDockerContainers(host.id); + setContainers(list); + } catch (e: any) { + toast.error(e?.message || "Failed to load containers"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [host.id], + ); + + useEffect(() => { + if (isVisible) load(); + }, [isVisible, load]); + + const act = async ( + c: DockerContainer, + action: "start" | "stop" | "restart" | "remove", + ) => { + setBusy(c.id); + try { + await dockerContainerAction(host.id, c.id, action); + toast.success(`${action} ${c.name}`); + await load(false); + } catch (e: any) { + toast.error(e?.message || `Failed to ${action}`); + } finally { + setBusy(null); + } + }; + + const showLogs = async (c: DockerContainer) => { + setLogs({ name: c.name, text: "Loading…" }); + try { + const text = await getDockerContainerLogs(host.id, c.id); + setLogs({ name: c.name, text: text || "(no output)" }); + } catch (e: any) { + setLogs({ name: c.name, text: e?.message || "Failed to load logs" }); + } + }; + + if (!isVisible) return null; + + return ( + + {loading && containers.length === 0 ? ( + + + + Loading containers… + + + ) : ( + { + setRefreshing(true); + load(false); + }} + tintColor={color("accent-brand")} + /> + } + > + {containers.length === 0 ? ( + + No containers found + + ) : ( + containers.map((c) => { + const running = /up|running/i.test(c.state || c.status || ""); + return ( + + + + + + + {c.name} + + + {c.image} · {c.status} + + + {busy === c.id ? ( + + ) : null} + + + {running ? ( + } label="Stop" onPress={() => act(c, "stop")} /> + ) : ( + } label="Start" onPress={() => act(c, "start")} accent /> + )} + } label="Restart" onPress={() => act(c, "restart")} /> + } label="Logs" onPress={() => showLogs(c)} /> + } label="Remove" onPress={() => act(c, "remove")} destructive /> + + + ); + }) + )} + + )} + + {/* Logs viewer */} + setLogs(null)}> + + + + {logs?.name} logs + + setLogs(null)} hitSlop={8}> + + + + + + {logs?.text} + + + + + + ); +} + +function DockerBtn({ + icon, + label, + onPress, + accent, + destructive, +}: { + icon: React.ReactNode; + label: string; + onPress: () => void; + accent?: boolean; + destructive?: boolean; +}) { + return ( + + {icon} + + {label} + + + ); +} diff --git a/app/tabs/sessions/file-manager/ContextMenu.tsx b/app/tabs/sessions/file-manager/ContextMenu.tsx index 3a2bd8a..68e1fbe 100644 --- a/app/tabs/sessions/file-manager/ContextMenu.tsx +++ b/app/tabs/sessions/file-manager/ContextMenu.tsx @@ -70,16 +70,16 @@ export function ContextMenu({ {}}> - + {fileName} @@ -90,103 +90,103 @@ export function ContextMenu({ {onView && fileType === "file" && ( handleAction(onView)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - View + 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" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Edit + Edit )} handleAction(onRename)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Rename + Rename handleAction(onCopy)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Copy + Copy handleAction(onCut)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Cut + 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" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Download + Download )} {onPermissions && ( handleAction(onPermissions)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Permissions + Permissions )} {onCompress && ( handleAction(onCompress)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Compress + Compress )} {onExtract && isArchive && ( handleAction(onExtract)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Extract + Extract )} handleAction(onDelete)} - className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" activeOpacity={0.7} > - Delete + Delete diff --git a/app/tabs/sessions/file-manager/FileItem.tsx b/app/tabs/sessions/file-manager/FileItem.tsx index c7a093a..8f26e63 100644 --- a/app/tabs/sessions/file-manager/FileItem.tsx +++ b/app/tabs/sessions/file-manager/FileItem.tsx @@ -58,11 +58,11 @@ export function FileItem({ className={`w-6 h-6 rounded border-2 items-center justify-center ${ isSelected ? "bg-blue-500 border-blue-600" - : "bg-dark-bg border-dark-border-light" + : "bg-background border-border" }`} > {isSelected && ( - + )} @@ -73,25 +73,25 @@ export function FileItem({ - + {name} {type === "directory" ? ( - Folder + Folder ) : ( <> {size !== undefined && ( - + {formatFileSize(size)} )} {modified && ( <> {size !== undefined && ( - + )} - + {formatDate(modified)} diff --git a/app/tabs/sessions/file-manager/FileList.tsx b/app/tabs/sessions/file-manager/FileList.tsx index abcd997..5941ba1 100644 --- a/app/tabs/sessions/file-manager/FileList.tsx +++ b/app/tabs/sessions/file-manager/FileList.tsx @@ -1,7 +1,6 @@ -import { ScrollView, RefreshControl, View, Text } from "react-native"; +import { ScrollView, RefreshControl, Text } from "react-native"; import { FileItem } from "./FileItem"; import { sortFiles } from "./utils/fileUtils"; -import { getColumnCount } from "@/app/utils/responsive"; interface FileListItem { name: string; @@ -48,7 +47,7 @@ export function FileList({ if (!isLoading && files.length === 0) { return ( } > - This folder is empty + This folder is empty ); } return ( 0 ? toolbarHeight + 12 : 12, }} diff --git a/app/tabs/sessions/file-manager/FileManager.tsx b/app/tabs/sessions/file-manager/FileManager.tsx index 944f3fa..10666de 100644 --- a/app/tabs/sessions/file-manager/FileManager.tsx +++ b/app/tabs/sessions/file-manager/FileManager.tsx @@ -114,7 +114,7 @@ export const FileManager = forwardRef( content: string; }>({ visible: false, file: null, content: "" }); - const keepaliveInterval = useRef(null); + const keepaliveInterval = useRef | null>(null); const connectToSSH = useCallback(async () => { try { @@ -462,9 +462,9 @@ export const FileManager = forwardRef( if (!isConnected) { return ( - - - Connecting to {host.name}... + + + Connecting to {host.name}... ( marginBottom: isLandscape ? 0 : insets.bottom, }} > - + Create New{" "} {createDialog.type === "folder" ? "Folder" : "File"} ( }} activeOpacity={0.7} > - + Cancel ( }} activeOpacity={0.7} > - + Create @@ -666,11 +666,11 @@ export const FileManager = forwardRef( marginBottom: isLandscape ? 0 : insets.bottom, }} > - + Rename Item ( }} activeOpacity={0.7} > - + Cancel ( }} activeOpacity={0.7} > - + Rename diff --git a/app/tabs/sessions/file-manager/FileManagerHeader.tsx b/app/tabs/sessions/file-manager/FileManagerHeader.tsx index daf8ae3..a278710 100644 --- a/app/tabs/sessions/file-manager/FileManagerHeader.tsx +++ b/app/tabs/sessions/file-manager/FileManagerHeader.tsx @@ -11,7 +11,6 @@ import { import { breadcrumbsFromPath, getBreadcrumbLabel } from "./utils/fileUtils"; import { getResponsivePadding, - getResponsiveFontSize, } from "@/app/utils/responsive"; import { BORDERS, diff --git a/app/tabs/sessions/file-manager/FileViewer.tsx b/app/tabs/sessions/file-manager/FileViewer.tsx index 6fea01f..cce7e79 100644 --- a/app/tabs/sessions/file-manager/FileViewer.tsx +++ b/app/tabs/sessions/file-manager/FileViewer.tsx @@ -129,9 +129,9 @@ export function FileViewer({ style={{ flex: 1, backgroundColor: "#18181b" }} keyboardVerticalOffset={0} > - + {fileName} {filePath} @@ -162,7 +162,7 @@ export function FileViewer({ <> @@ -170,7 +170,7 @@ export function FileViewer({ @@ -201,19 +201,18 @@ export function FileViewer({ {readOnly && ( - Read-only mode + Read-only mode )} {session.title} diff --git a/app/tabs/sessions/server-stats/ServerStats.tsx b/app/tabs/sessions/server-stats/ServerStats.tsx index 26852c8..3c032d3 100644 --- a/app/tabs/sessions/server-stats/ServerStats.tsx +++ b/app/tabs/sessions/server-stats/ServerStats.tsx @@ -13,14 +13,13 @@ import { ActivityIndicator, RefreshControl, TouchableOpacity, + type DimensionValue, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Cpu, MemoryStick, HardDrive, - Activity, - Clock, Server, } from "lucide-react-native"; import { getServerMetricsById, executeSnippet } from "../../../main-axios"; @@ -36,7 +35,6 @@ import { BACKGROUNDS, BORDER_COLORS, RADIUS, - TEXT_COLORS, } from "@/app/constants/designTokens"; interface ServerStatsProps { @@ -65,7 +63,7 @@ export const ServerStats = forwardRef( const [executingActions, setExecutingActions] = useState>( new Set(), ); - const refreshIntervalRef = useRef(null); + const refreshIntervalRef = useRef | null>(null); const padding = getResponsivePadding(isLandscape); const columnCount = getColumnCount(width, isLandscape, 350); @@ -129,8 +127,10 @@ export const ServerStats = forwardRef( }; }, [isVisible, fetchMetrics]); - const cardWidth = - isLandscape && columnCount > 1 ? `${100 / columnCount - 1}%` : "100%"; + const cardWidth: DimensionValue = + isLandscape && columnCount > 1 + ? (`${100 / columnCount - 1}%` as DimensionValue) + : "100%"; const formatUptime = (seconds: number | null): string => { if (seconds === null || seconds === undefined) return "N/A"; @@ -243,7 +243,7 @@ export const ServerStats = forwardRef( backgroundColor: BACKGROUNDS.DARKEST, }} > - + ( ( } > @@ -360,7 +360,7 @@ export const ServerStats = forwardRef( onPress={() => handleQuickAction(action)} disabled={isExecuting} style={{ - backgroundColor: isExecuting ? "#374151" : "#22C55E", + backgroundColor: isExecuting ? "#374151" : "#f59145", paddingHorizontal: 16, paddingVertical: 10, borderRadius: RADIUS.BUTTON, @@ -579,4 +579,6 @@ export const ServerStats = forwardRef( }, ); +ServerStats.displayName = "ServerStats"; + export default ServerStats; diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts index bb29aee..19f6f71 100644 --- a/app/tabs/sessions/terminal/NativeWebSocketManager.ts +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -37,6 +37,10 @@ export interface HostKeyData { export interface NativeWSConfig { hostConfig: TerminalHostConfig; + /** Stable tab instance id for cross-device session tracking (open-tabs). */ + tabInstanceId?: string; + /** Backend session id to attach to on first connect (reviving a tab). */ + initialSessionId?: string | null; onStateChange: (state: WsState, data?: Record) => void; onData: (data: string) => void; onTotpRequired: (prompt: string, isPassword: boolean) => void; @@ -50,6 +54,8 @@ export interface NativeWSConfig { onPostConnectionSetup: () => void; onDisconnected: (hostName: string) => void; onConnectionFailed: (message: string) => void; + /** Fired when the backend session id is created/attached/cleared. */ + onSessionIdChange?: (sessionId: string | null) => void; } export class NativeWebSocketManager { @@ -75,6 +81,16 @@ export class NativeWebSocketManager { constructor(config: NativeWSConfig) { this.config = config; + // Seed the backend session id when reviving a backgrounded/cross-device tab. + if (config.initialSessionId) { + this.serverSessionId = config.initialSessionId; + } + } + + private setServerSessionId(id: string | null) { + if (this.serverSessionId === id) return; + this.serverSessionId = id; + this.config.onSessionIdChange?.(id); } async connect(cols: number, rows: number): Promise { @@ -329,6 +345,7 @@ export class NativeWebSocketManager { sessionId: this.serverSessionId, cols: this.cols, rows: this.rows, + tabInstanceId: this.config.tabInstanceId, }, }), ); @@ -340,6 +357,7 @@ export class NativeWebSocketManager { cols: this.cols, rows: this.rows, hostConfig: this.config.hostConfig, + tabInstanceId: this.config.tabInstanceId, }, }), ); @@ -417,16 +435,16 @@ export class NativeWebSocketManager { this.config.onPostConnectionSetup(); } } else if (msg.type === "disconnected") { - this.serverSessionId = null; + this.setServerSessionId(null); this.config.onDisconnected(this.config.hostConfig.name); } else if (msg.type === "pong") { } else if (msg.type === "resized") { } else if (msg.type === "sessionCreated") { - this.serverSessionId = msg.sessionId as string; + this.setServerSessionId(msg.sessionId as string); } else if (msg.type === "sessionAttached") { - this.serverSessionId = msg.sessionId as string; + this.setServerSessionId(msg.sessionId as string); } else if (msg.type === "sessionExpired") { - this.serverSessionId = null; + this.setServerSessionId(null); this.pendingReattach = false; if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send( @@ -436,12 +454,13 @@ export class NativeWebSocketManager { cols: this.cols, rows: this.rows, hostConfig: this.config.hostConfig, + tabInstanceId: this.config.tabInstanceId, }, }), ); } } else if (msg.type === "sessionTakenOver") { - this.serverSessionId = null; + this.setServerSessionId(null); this.shouldNotReconnect = true; this.config.onDisconnected(this.config.hostConfig.name); } diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index d00b255..458827d 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -56,6 +56,12 @@ interface TerminalProps { title?: string; onClose?: () => void; onBackgroundColorChange?: (color: string) => void; + /** Stable tab instance id (cross-device session tracking). */ + tabInstanceId?: string; + /** Backend session id to attach to on first connect (reviving a tab). */ + initialSessionId?: string | null; + /** Fired when the backend session id is created/attached/cleared. */ + onSessionIdChange?: (sessionId: string | null) => void; } export type TerminalHandle = { @@ -76,6 +82,9 @@ const TerminalComponent = forwardRef( title = "Terminal", onClose, onBackgroundColorChange, + tabInstanceId, + initialSessionId, + onSessionIdChange, }, ref, ) => { @@ -829,6 +838,9 @@ const TerminalComponent = forwardRef( wsManagerRef.current = new NativeWebSocketManager({ hostConfig: hostConfig as TerminalHostConfig, + tabInstanceId, + initialSessionId, + onSessionIdChange, onStateChange: (state, data) => { switch (state) { case "connecting": @@ -1017,8 +1029,6 @@ const TerminalComponent = forwardRef( cacheEnabled={false} cacheMode="LOAD_NO_CACHE" androidLayerType="hardware" - onScroll={(event) => {}} - scrollEventThrottle={16} onMessage={handleWebViewMessage} onError={(syntheticEvent) => { const { nativeEvent } = syntheticEvent; @@ -1108,7 +1118,7 @@ const TerminalComponent = forwardRef( minWidth: 280, }} > - + + ( setMode(tab.id)} style={{ borderRightWidth: diff --git a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx index e3e38f6..85cae52 100644 --- a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx +++ b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx @@ -136,7 +136,7 @@ export default function CustomKeyboard({ }; return ( - + {row.label && ( - + {row.label} diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx index 95672c3..3c4c38f 100644 --- a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx +++ b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx @@ -87,7 +87,7 @@ export default function KeyboardKey({ delayLongPress={500} > {label} diff --git a/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx b/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx index c0c6fde..8458be2 100644 --- a/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx +++ b/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx @@ -116,11 +116,11 @@ export default function SnippetsBar({ const unfolderedSnippets = getSnippetsInFolder(null); return ( - + {loading ? ( - - + + Loading snippets... @@ -137,7 +137,7 @@ export default function SnippetsBar({ {unfolderedSnippets.length > 0 && ( Uncategorized - + ({unfolderedSnippets.length}) - + {collapsedFolders.has(0) ? "▶" : "▼"} @@ -167,7 +167,7 @@ export default function SnippetsBar({ unfolderedSnippets.map((snippet) => ( executeSnippet(snippet)} > {snippet.name} @@ -193,28 +193,28 @@ export default function SnippetsBar({ return ( toggleFolder(folder.id)} > {folder.name} - + ({folderSnippets.length}) - + {isCollapsed ? "▶" : "▼"} @@ -223,7 +223,7 @@ export default function SnippetsBar({ folderSnippets.map((snippet) => ( executeSnippet(snippet)} > {snippet.name} @@ -245,7 +245,7 @@ export default function SnippetsBar({ {snippets.length === 0 && ( - + No snippets yet diff --git a/app/tabs/sessions/tunnel/TunnelCard.tsx b/app/tabs/sessions/tunnel/TunnelCard.tsx index 509cb81..c7260a9 100644 --- a/app/tabs/sessions/tunnel/TunnelCard.tsx +++ b/app/tabs/sessions/tunnel/TunnelCard.tsx @@ -224,7 +224,7 @@ const TunnelCard: React.FC = ({ (null); const [allHosts, setAllHosts] = useState([]); const [currentHostConfig, setCurrentHostConfig] = useState(hostConfig); - const refreshIntervalRef = useRef(null); + const refreshIntervalRef = useRef | null>(null); const padding = getResponsivePadding(isLandscape); const columnCount = getColumnCount(width, isLandscape, 350); @@ -250,8 +248,10 @@ export const TunnelManager = forwardRef< } }; - const cardWidth = - isLandscape && columnCount > 1 ? `${100 / columnCount - 1}%` : "100%"; + const cardWidth: DimensionValue = + isLandscape && columnCount > 1 + ? (`${100 / columnCount - 1}%` as DimensionValue) + : "100%"; if (!isVisible) { return null; @@ -275,7 +275,7 @@ export const TunnelManager = forwardRef< backgroundColor: BACKGROUNDS.DARKEST, }} > - + - This host doesn't have any SSH tunnels configured. + This host has no SSH tunnels configured. } > @@ -444,4 +444,6 @@ export const TunnelManager = forwardRef< ); }); +TunnelManager.displayName = "TunnelManager"; + export default TunnelManager; diff --git a/app/tabs/settings/KeyboardCustomization.tsx b/app/tabs/settings/KeyboardCustomization.tsx index 74381a3..6e54776 100644 --- a/app/tabs/settings/KeyboardCustomization.tsx +++ b/app/tabs/settings/KeyboardCustomization.tsx @@ -275,10 +275,10 @@ export default function KeyboardCustomization() { const renderPresets = () => ( - + Keyboard Presets - + Choose a preset layout optimized for different use cases @@ -293,16 +293,16 @@ export default function KeyboardCustomization() { }`} > - + {preset.name} {config.preset === preset.id && ( - - ACTIVE + + ACTIVE )} - {preset.description} + {preset.description} ))} @@ -311,8 +311,8 @@ export default function KeyboardCustomization() { Custom Layout - - You've made custom changes. Select a preset above to reset to a + + You have made custom changes. Select a preset above to reset to a predefined layout. @@ -516,15 +516,15 @@ export default function KeyboardCustomization() { const renderSettings = () => ( - + Keyboard Settings - + Adjust keyboard appearance and behavior - + Key Size @@ -541,8 +541,8 @@ export default function KeyboardCustomization() { {size.charAt(0).toUpperCase() + size.slice(1)} @@ -554,47 +554,47 @@ export default function KeyboardCustomization() { - Compact Mode - + Compact Mode + Tighter spacing for more keys on screen - + Haptic Feedback - + Vibrate on key press - Show Hints - - Display "Customize in Settings" hint + Show Hints + + Display the “Customize in Settings” hint @@ -621,11 +621,11 @@ export default function KeyboardCustomization() { > router.back()}> - + ← Back - + Keyboard Customization @@ -649,7 +649,7 @@ export default function KeyboardCustomization() { > {tab.label} @@ -689,10 +689,10 @@ export default function KeyboardCustomization() { onPress={() => setShowResetConfirm(false)} > - + Confirm Reset - + {resetType === "all" ? "This will reset all keyboard customizations to default settings." : resetType === "topbar" @@ -704,7 +704,7 @@ export default function KeyboardCustomization() { onPress={() => setShowResetConfirm(false)} className="flex-1 bg-[#27272a] border border-[#3f3f46] rounded-lg p-3" > - + Cancel @@ -712,7 +712,7 @@ export default function KeyboardCustomization() { onPress={handleReset} className="flex-1 bg-red-600 rounded-lg p-3" > - + Reset diff --git a/app/tabs/settings/Settings.tsx b/app/tabs/settings/Settings.tsx index 355a3d6..a526a40 100644 --- a/app/tabs/settings/Settings.tsx +++ b/app/tabs/settings/Settings.tsx @@ -1,112 +1,433 @@ -import { ScrollView, Text, TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useEffect, useState } from "react"; +import { ScrollView, View, Pressable } from "react-native"; import { useRouter } from "expo-router"; +import { + User, + Palette, + Shield, + SlidersHorizontal, + ChevronRight, + Type, + LogOut, + Lock, + KeyRound, +} from "lucide-react-native"; 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"; +import { useTheme } from "@/app/contexts/ThemeContext"; +import { useAppLock } from "@/app/contexts/AppLockContext"; +import { + clearAuth, + logoutUser, + getUserInfo, + getVersionInfo, + changePassword, +} from "@/app/main-axios"; +import { Screen } from "@/app/components/Screen"; +import { + Text, + Button, + Input, + Label, + AccordionSection, + SettingRow, + FakeSwitch, + SegmentedControl, + Dialog, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { + ACCENT_PRESET_COLORS, + FONT_SIZES, + THEMES, + THEME_LABELS, + type ThemeId, + type FontSizeId, +} from "@/app/constants/theme"; +import { toast } from "@/app/utils/toast"; export default function Settings() { const router = useRouter(); - const { isLandscape } = useOrientation(); + const color = useThemeColor(); const { setAuthenticated, setShowLoginForm, setShowServerManager, - setSelectedServer, - selectedServer, } = useAppContext(); const { clearAllSessions } = useTerminalSessions(); - const insets = useSafeAreaInsets(); + const { theme, setTheme, accent, setAccent, fontSize, setFontSize } = + useTheme(); + const appLock = useAppLock(); - const padding = getResponsivePadding(isLandscape); + const [username, setUsername] = useState("—"); + const [isAdmin, setIsAdmin] = useState(false); + const [totpEnabled, setTotpEnabled] = useState(false); + const [version, setVersion] = useState(""); + const [open, setOpen] = useState("appearance"); + + // App-lock PIN dialog + const [pinDialog, setPinDialog] = useState(false); + const [pin, setPin] = useState(""); + // Change-password dialog + const [pwDialog, setPwDialog] = useState(false); + const [curPw, setCurPw] = useState(""); + const [newPw, setNewPw] = useState(""); + + useEffect(() => { + getUserInfo() + .then((u) => { + setUsername(u.username ?? "—"); + setIsAdmin(!!u.is_admin); + setTotpEnabled(!!u.totp_enabled); + }) + .catch(() => {}); + getVersionInfo() + .then((v) => setVersion(v?.localVersion ?? v?.version ?? "")) + .catch(() => {}); + }, []); + + const toggle = (id: string) => setOpen((o) => (o === id ? null : id)); const handleLogout = async () => { try { await logoutUser(); - await clearAuth(); - clearAllSessions(); - setAuthenticated(false); setShowLoginForm(true); setShowServerManager(false); - } catch (error) { - console.error("[Settings] Error during logout:", error); + } catch { + // best-effort + } + }; + + const handleAppLockToggle = async (v: boolean) => { + if (v) { + setPin(""); + setPinDialog(true); + } else { + await appLock.disable(); + toast.success("App lock disabled"); + } + }; + + const confirmPin = async () => { + if (pin.length < 4) { + toast.error("PIN must be at least 4 digits"); + return; + } + await appLock.enable(pin); + setPinDialog(false); + toast.success("App lock enabled"); + }; + + const handleChangePassword = async () => { + if (!curPw || newPw.length < 6) { + toast.error("Enter current password and a new one (min 6 chars)"); + return; + } + try { + await changePassword(curPw, newPw); + toast.success("Password updated"); + setPwDialog(false); + setCurPw(""); + setNewPw(""); + } catch (e: any) { + toast.error(e?.response?.data?.error || "Failed to update password"); } }; return ( - - - + + {/* Account */} + } + open={open === "account"} + onToggle={() => toggle("account")} > - Settings - - - - - Terminal - - - router.push("/tabs/settings/TerminalCustomization" as any) - } - className="bg-[#1a1a1a] border border-[#303032] px-6 py-4 rounded-lg flex-row items-center justify-between" - > - - - Customize Terminal + + + + + {username} - - Font size and appearance + + + + + + {isAdmin ? "Administrator" : "User"} + + + + + + + {totpEnabled ? "Enabled" : "Disabled"} - - - + {version ? ( + + + + v{version} + + + ) : null} + + + - - - Keyboard - - - router.push("/tabs/settings/KeyboardCustomization" as any) - } - className="bg-[#1a1a1a] border border-[#303032] px-6 py-4 rounded-lg flex-row items-center justify-between" - > - - - Customize Keyboard - - - Layouts, keys, and preferences - + {/* Appearance — headline feature */} + } + open={open === "appearance"} + onToggle={() => toggle("appearance")} + > + + {/* Theme */} + + + + {THEMES.map((th) => { + const active = theme === th.id; + return ( + setTheme(th.id as ThemeId)} + className={`flex-row items-center gap-1.5 px-2 py-1.5 border ${active ? "border-accent-brand/50 bg-accent-brand/10" : "border-border"}`} + > + {th.preview !== "auto" ? ( + + ) : null} + + {THEME_LABELS[th.id]} + + + ); + })} + - - - - - Account - - Logout - - - - To delete your account, visit your self-hosted Termix instance and - log in. - + {/* Accent color */} + + + + {ACCENT_PRESET_COLORS.map((ac) => { + const active = + accent.toLowerCase() === ac.value.toLowerCase(); + return ( + setAccent(ac.value)} + style={{ backgroundColor: ac.value }} + className={`w-8 h-8 border-2 ${active ? "border-foreground" : "border-transparent"}`} + /> + ); + })} + + + + { + if (/^#[0-9a-fA-F]{6}$/.test(v.trim())) setAccent(v.trim()); + else setAccent(v); // keep typing; ThemeProvider validates + }} + autoCapitalize="none" + placeholder="#f59145" + /> + hex + + + + {/* Font size */} + + + + + + + value={fontSize} + onChange={setFontSize} + options={FONT_SIZES.map((f) => ({ id: f.id, label: f.label }))} + /> + + + + + {/* Security */} + } + open={open === "security"} + onToggle={() => toggle("security")} + > + + + + + + + + + + + {/* Customization */} + } + open={open === "customization"} + onToggle={() => toggle("customization")} + > + + + router.push("/tabs/settings/TerminalCustomization" as any) + } + className="flex-row items-center justify-between py-3 border-b border-border" + > + + + Terminal + + + Font, theme, cursor, scrollback + + + + + + router.push("/tabs/settings/KeyboardCustomization" as any) + } + className="flex-row items-center justify-between py-3" + > + + + Keyboard + + + Layout, keys, presets + + + + + + + + + {/* App-lock PIN dialog */} + setPinDialog(false)} + title="Set App Lock PIN" + description="Choose a 4+ digit PIN. Biometrics will be used when available." + icon={} + footer={ + <> + + + + } + > + setPin(v.replace(/\D/g, "").slice(0, 8))} + keyboardType="number-pad" + secureTextEntry + placeholder="••••" + autoFocus + /> + + + {/* Change-password dialog */} + setPwDialog(false)} + title="Change Password" + icon={} + footer={ + <> + + + + } + > + + + + + + + + + - - + + ); } diff --git a/app/tabs/settings/TerminalCustomization.tsx b/app/tabs/settings/TerminalCustomization.tsx index 1427682..268e262 100644 --- a/app/tabs/settings/TerminalCustomization.tsx +++ b/app/tabs/settings/TerminalCustomization.tsx @@ -90,11 +90,11 @@ export default function TerminalCustomization() { > router.back()}> - + ← Back - + Terminal Customization @@ -105,16 +105,16 @@ export default function TerminalCustomization() { className="flex-1 px-4 py-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) }} > - + Terminal Settings - + Customize terminal appearance and behavior - Font - + Font + Select the terminal font family. Nerd Font support depends on the selected font being available to the WebView. @@ -137,21 +137,21 @@ export default function TerminalCustomization() { {option.label} Aa Bb Cc 123  {isActive && ( - - + + ACTIVE @@ -164,10 +164,10 @@ export default function TerminalCustomization() { - + Font Size - + Base font size for terminal text. The actual size will be adjusted based on your screen width. This number will override the font size you configured on a host in the Termix Web UI. @@ -188,19 +188,19 @@ export default function TerminalCustomization() { {option.label} - + {option.value}px base size {config.fontSize === option.value && ( - - + + ACTIVE @@ -221,20 +221,20 @@ export default function TerminalCustomization() { Custom - + {isCustomFontSize ? `${config.fontSize}px base size` : "Enter any custom size"} {isCustomFontSize && ( - - + + ACTIVE @@ -276,10 +276,10 @@ export default function TerminalCustomization() { }} > - + Custom Font Size - + Enter your preferred font size for the terminal. - + Cancel - + Apply @@ -338,10 +338,10 @@ export default function TerminalCustomization() { onPress={() => setShowResetConfirm(false)} > - + Confirm Reset - + This will reset all terminal customizations to default settings. @@ -349,7 +349,7 @@ export default function TerminalCustomization() { onPress={() => setShowResetConfirm(false)} className="flex-1 rounded-lg border border-[#3f3f46] bg-[#27272a] p-3" > - + Cancel @@ -357,7 +357,7 @@ export default function TerminalCustomization() { onPress={handleReset} className="flex-1 rounded-lg bg-red-600 p-3" > - + Reset diff --git a/app/tabs/settings/components/DraggableKeyList.tsx b/app/tabs/settings/components/DraggableKeyList.tsx index 378a051..4d3796b 100644 --- a/app/tabs/settings/components/DraggableKeyList.tsx +++ b/app/tabs/settings/components/DraggableKeyList.tsx @@ -37,12 +37,12 @@ export function renderKeyItem({ - {item.label} + {item.label} - {item.category} + {item.category} {item.description && ( - + {item.description} )} diff --git a/app/tabs/settings/components/DraggableRowList.tsx b/app/tabs/settings/components/DraggableRowList.tsx index 4052e9f..515781f 100644 --- a/app/tabs/settings/components/DraggableRowList.tsx +++ b/app/tabs/settings/components/DraggableRowList.tsx @@ -1,7 +1,6 @@ import React, { useState } from "react"; import { View, Text, TouchableOpacity, Switch } from "react-native"; import { KeyboardRow, KeyConfig } from "@/types/keyboard"; -import { renderKeyItem } from "./DraggableKeyList"; import { GripVertical } from "lucide-react-native"; interface RenderRowItemProps { @@ -65,15 +64,15 @@ export function renderRowItem({ activeOpacity={0.6} > - + {item.label} - + {item.keys.length} keys • {item.category} - + {isExpanded ? "▼" : "▶"} @@ -82,7 +81,7 @@ export function renderRowItem({ onToggleVisibility(item.id)} - trackColor={{ false: "#3f3f46", true: "#22C55E" }} + trackColor={{ false: "#3f3f46", true: "#f59145" }} thumbColor={item.visible ? "#ffffff" : "#9ca3af"} /> diff --git a/app/tabs/settings/components/KeySelector.tsx b/app/tabs/settings/components/KeySelector.tsx index a766bdc..bbfafcb 100644 --- a/app/tabs/settings/components/KeySelector.tsx +++ b/app/tabs/settings/components/KeySelector.tsx @@ -6,7 +6,6 @@ import { TouchableOpacity, ScrollView, TextInput, - Pressable, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { KeyConfig, KeyCategory } from "@/types/keyboard"; @@ -87,9 +86,9 @@ export default function KeySelector({ style={{ paddingTop: insets.top + 12, paddingBottom: 12 }} > - {title} + {title} - + Done @@ -98,7 +97,7 @@ export default function KeySelector({ All @@ -143,8 +142,8 @@ export default function KeySelector({ {cat.label} @@ -157,7 +156,7 @@ export default function KeySelector({ {filteredKeys.length === 0 ? ( - + {searchQuery ? "No keys found matching your search" : "No keys available"} @@ -174,22 +173,22 @@ export default function KeySelector({ - + {key.label} - + {key.category} {key.description && ( - + {key.description} )} - - + + Add diff --git a/app/tabs/settings/components/UnifiedDraggableList.tsx b/app/tabs/settings/components/UnifiedDraggableList.tsx index 646b2f8..42eaa2e 100644 --- a/app/tabs/settings/components/UnifiedDraggableList.tsx +++ b/app/tabs/settings/components/UnifiedDraggableList.tsx @@ -74,11 +74,11 @@ export default function UnifiedDraggableList({ - + {item.title} {item.subtitle && ( - + {item.subtitle} )} @@ -86,9 +86,9 @@ export default function UnifiedDraggableList({ {item.onAddPress && ( - + {item.addButtonLabel || "+ Add"} @@ -137,15 +137,15 @@ export default function UnifiedDraggableList({ return ( - + Keys in this row {item.onAddPress && ( - + + Add Key @@ -168,7 +168,7 @@ export default function UnifiedDraggableList({ > {item.label} diff --git a/app/utils/toast.ts b/app/utils/toast.ts index cb9b95e..be109e6 100644 --- a/app/utils/toast.ts +++ b/app/utils/toast.ts @@ -1,53 +1,35 @@ -import { toast } from "sonner-native"; +import { toast as sonnerToast } from "sonner-native"; -export const showToast = { - success: (message: string) => { - toast.success(message, { - style: { - backgroundColor: "#18181b", - borderWidth: 1, - borderColor: "#16a34a", - }, - }); - }, - - error: (message: string) => { - toast.error(message, { - style: { - backgroundColor: "#18181b", - borderWidth: 1, - borderColor: "#dc2626", - }, - }); - }, +/** + * Toast helpers. Visual styling (background/border) is handled globally by the + * themed in app/_layout.tsx, so we only pass the message + intent + * here. Status accent borders are applied per-intent for quick recognition. + */ +const STATUS_BORDER: Record = { + success: "#16a34a", + error: "#dc2626", + warning: "#d97706", + info: "#2563eb", +}; - warning: (message: string) => { - toast.warning(message, { - style: { - backgroundColor: "#18181b", - borderWidth: 1, - borderColor: "#d97706", - }, - }); - }, +function withBorder(color?: string) { + return color ? { style: { borderColor: color } } : undefined; +} - info: (message: string) => { - toast.info(message, { - style: { - backgroundColor: "#18181b", - borderWidth: 1, - borderColor: "#2563eb", - }, - }); - }, +export const toast = { + success: (m: string) => sonnerToast.success(m, withBorder(STATUS_BORDER.success)), + error: (m: string) => sonnerToast.error(m, withBorder(STATUS_BORDER.error)), + warning: (m: string) => + sonnerToast.warning(m, withBorder(STATUS_BORDER.warning)), + info: (m: string) => sonnerToast.info(m, withBorder(STATUS_BORDER.info)), + message: (m: string) => sonnerToast(m), +}; - default: (message: string) => { - toast(message, { - style: { - backgroundColor: "#18181b", - borderWidth: 1, - borderColor: "#27272a", - }, - }); - }, +// Back-compat alias for existing call sites that import showToast. +export const showToast = { + success: toast.success, + error: toast.error, + warning: toast.warning, + info: toast.info, + default: toast.message, }; diff --git a/global.css b/global.css index bd6213e..e73bb04 100644 --- a/global.css +++ b/global.css @@ -1,3 +1,89 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +/* + * Termix Mobile theme tokens — ported 1:1 from the web app + * (../Termix/Termix/src/ui/index.css). Colors are stored as + * space-separated RGB triplets so Tailwind's works + * (e.g. bg-accent-brand/10, border-border/60). + * + * The base :root (light) and .dark blocks below give NativeWind a + * static baseline for the `dark:` variant. Full multi-theme + custom + * accent switching happens at runtime via vars() in ThemeContext, which + * applies the matching THEME_VARS map (see app/constants/theme.ts). + */ + +:root { + --accent-brand: 245 145 69; /* #f59145 */ + + --background: 255 255 255; + --foreground: 17 18 16; + --card: 255 255 255; + --card-foreground: 17 18 16; + --popover: 255 255 255; + --popover-foreground: 17 18 16; + --primary: 24 25 23; + --primary-foreground: 250 250 250; + --secondary: 245 245 245; + --secondary-foreground: 24 25 23; + --muted: 245 245 245; + --muted-foreground: 115 115 115; + --accent: 245 245 245; + --accent-foreground: 24 25 23; + --destructive: 231 0 11; + --border: 229 229 229; + --input: 229 229 229; + --ring: 161 161 161; + --surface: 245 245 245; + --surface-dim: 235 235 235; + --chart-1: 212 212 212; + --chart-2: 115 115 115; + --chart-3: 82 82 82; + --chart-4: 64 64 64; + --chart-5: 38 38 38; + --sidebar: 250 250 250; + --sidebar-foreground: 17 18 16; + --sidebar-primary: 24 25 23; + --sidebar-primary-foreground: 250 250 250; + --sidebar-accent: 245 245 245; + --sidebar-accent-foreground: 24 25 23; + --sidebar-border: 229 229 229; + --sidebar-ring: 161 161 161; +} + +.dark { + --background: 12 13 11; + --foreground: 250 250 250; + --card: 24 25 23; + --card-foreground: 250 250 250; + --popover: 24 25 23; + --popover-foreground: 250 250 250; + --primary: 229 229 229; + --primary-foreground: 24 25 23; + --secondary: 38 38 38; + --secondary-foreground: 250 250 250; + --muted: 35 35 35; + --muted-foreground: 164 164 164; + --accent: 35 35 35; + --accent-foreground: 250 250 250; + --destructive: 255 100 103; + --border: 50 50 50; + --input: 56 56 56; + --ring: 115 115 115; + --surface: 18 19 17; + --surface-dim: 14 15 13; + --chart-1: 212 212 212; + --chart-2: 115 115 115; + --chart-3: 82 82 82; + --chart-4: 64 64 64; + --chart-5: 38 38 38; + --sidebar: 20 21 19; + --sidebar-foreground: 250 250 250; + --sidebar-primary: 20 71 230; + --sidebar-primary-foreground: 250 250 250; + --sidebar-accent: 35 35 35; + --sidebar-accent-foreground: 250 250 250; + --sidebar-border: 50 50 50; + --sidebar-ring: 115 115 115; +} diff --git a/package-lock.json b/package-lock.json index 45210e0..f1ff942 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "termix-mobile", "version": "1.3.2", "dependencies": { + "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "^2.2.0", @@ -24,7 +25,9 @@ "expo-haptics": "~15.0.7", "expo-image": "~3.0.9", "expo-linking": "~8.0.8", + "expo-local-authentication": "~17.0.8", "expo-router": "~6.0.12", + "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", @@ -2171,6 +2174,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@expo-google-fonts/jetbrains-mono": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/jetbrains-mono/-/jetbrains-mono-0.4.1.tgz", + "integrity": "sha512-CslACrtMOcRwoWXCO7OMEI+9w3fukWSoBtvNz46OqPoogEuuoY0tkDY1O8sFumk8t0pC6Cx0Xr95O0TOQhpkug==", + "license": "MIT AND OFL-1.1" + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz", @@ -7750,6 +7759,18 @@ "react-native": "*" } }, + "node_modules/expo-local-authentication": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-17.0.8.tgz", + "integrity": "sha512-Q5fXHhu6w3pVPlFCibU72SYIAN+9wX7QpFn9h49IUqs0Equ44QgswtGrxeh7fdnDqJrrYGPet5iBzjnE70uolA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-manifests": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.8.tgz", @@ -7876,6 +7897,15 @@ "node": ">=10" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.1.tgz", diff --git a/package.json b/package.json index a8744f3..aa378c3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build-ios": "eas build --platform ios --profile development" }, "dependencies": { + "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", "@react-native-async-storage/async-storage": "^2.2.0", @@ -32,7 +33,9 @@ "expo-haptics": "~15.0.7", "expo-image": "~3.0.9", "expo-linking": "~8.0.8", + "expo-local-authentication": "~17.0.8", "expo-router": "~6.0.12", + "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", diff --git a/tailwind.config.js b/tailwind.config.js index 1997085..c2f39dd 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,38 +1,86 @@ /** @type {import('tailwindcss').Config} */ + +// Maps a CSS variable holding an "R G B" triplet to a Tailwind color that +// supports the / opacity modifier (e.g. bg-accent-brand/10). +const withAlpha = (variable) => `rgb(var(${variable}) / )`; + module.exports = { content: ["./app/**/*.{js,jsx,ts,tsx}"], presets: [require("nativewind/preset")], + darkMode: "class", theme: { extend: { colors: { - 'dark-bg': '#18181b', - 'dark-bg-darker': '#0e0e10', - 'dark-bg-darkest': '#09090b', - 'dark-bg-input': '#222225', - 'dark-bg-button': '#23232a', - 'dark-bg-active': '#1d1d1f', - 'dark-bg-header': '#131316', - 'dark-border': '#303032', - 'dark-border-active': '#2d2d30', - 'dark-border-hover': '#434345', - 'dark-hover': '#2d2d30', - 'dark-active': '#2a2a2c', - 'dark-pressed': '#1a1a1c', - 'dark-hover-alt': '#2a2a2d', - 'dark-border-light': '#5a5a5d', - 'dark-bg-light': '#141416', - 'dark-border-medium': '#373739', - 'dark-bg-very-light': '#101014', - 'dark-bg-panel': '#1b1b1e', - 'dark-border-panel': '#222224', - 'dark-bg-panel-hover': '#232327', + "accent-brand": withAlpha("--accent-brand"), + background: withAlpha("--background"), + foreground: withAlpha("--foreground"), + card: { + DEFAULT: withAlpha("--card"), + foreground: withAlpha("--card-foreground"), + }, + popover: { + DEFAULT: withAlpha("--popover"), + foreground: withAlpha("--popover-foreground"), + }, + primary: { + DEFAULT: withAlpha("--primary"), + foreground: withAlpha("--primary-foreground"), + }, + secondary: { + DEFAULT: withAlpha("--secondary"), + foreground: withAlpha("--secondary-foreground"), + }, + muted: { + DEFAULT: withAlpha("--muted"), + foreground: withAlpha("--muted-foreground"), + }, + accent: { + DEFAULT: withAlpha("--accent"), + foreground: withAlpha("--accent-foreground"), + }, + destructive: withAlpha("--destructive"), + border: withAlpha("--border"), + input: withAlpha("--input"), + ring: withAlpha("--ring"), + surface: { + DEFAULT: withAlpha("--surface"), + dim: withAlpha("--surface-dim"), + }, + chart: { + 1: withAlpha("--chart-1"), + 2: withAlpha("--chart-2"), + 3: withAlpha("--chart-3"), + 4: withAlpha("--chart-4"), + 5: withAlpha("--chart-5"), + }, + sidebar: { + DEFAULT: withAlpha("--sidebar"), + foreground: withAlpha("--sidebar-foreground"), + primary: withAlpha("--sidebar-primary"), + "primary-foreground": withAlpha("--sidebar-primary-foreground"), + accent: withAlpha("--sidebar-accent"), + "accent-foreground": withAlpha("--sidebar-accent-foreground"), + border: withAlpha("--sidebar-border"), + ring: withAlpha("--sidebar-ring"), + }, + }, + fontFamily: { + mono: ["JetBrainsMono", "monospace"], + sans: ["JetBrainsMono", "monospace"], }, borderRadius: { - 'button': '6px', - 'card': '12px', - 'small': '4px', + // Square corners everywhere — matches the web redesign. + none: "0px", + sm: "0px", + DEFAULT: "0px", + md: "0px", + lg: "0px", + xl: "0px", + // The two intentional exceptions in the web design: + check: "4px", + full: "9999px", }, }, }, plugins: [], -} \ No newline at end of file +}; diff --git a/types/index.ts b/types/index.ts index c2b312e..32c1e8a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -46,6 +46,28 @@ export interface SSHHost { terminalConfig?: TerminalConfig; createdAt: string; updatedAt: string; + + // --- Modern multi-protocol model (matches the redesigned web backend). + // All optional so legacy SSH-only hosts keep working unchanged. + enableSsh?: boolean; + enableRdp?: boolean; + enableVnc?: boolean; + enableTelnet?: boolean; + enableDocker?: boolean; + sshPort?: number; + notes?: string; + // Remote desktop (Guacamole) protocol fields + rdpUser?: string; + rdpPassword?: string; + rdpDomain?: string; + rdpPort?: number; + vncUser?: string; + vncPassword?: string; + vncPort?: number; + telnetUser?: string; + telnetPassword?: string; + telnetPort?: number; + dockerConfig?: string; } export interface JumpHostData { @@ -82,6 +104,24 @@ export interface SSHHostData { quickActions?: QuickActionData[]; statsConfig?: string | Record; terminalConfig?: TerminalConfig; + + // --- Modern multi-protocol model (optional; SSH-only hosts omit these). + enableSsh?: boolean; + enableRdp?: boolean; + enableVnc?: boolean; + enableTelnet?: boolean; + enableDocker?: boolean; + notes?: string; + rdpUser?: string; + rdpPassword?: string; + rdpDomain?: string; + rdpPort?: number; + vncUser?: string; + vncPassword?: string; + vncPort?: number; + telnetUser?: string; + telnetPassword?: string; + telnetPort?: number; } export interface SSHFolder { From a8bedb627b7cc67dc9c1187265bad2ed452d7c1d Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 29 May 2026 16:22:38 -0500 Subject: [PATCH 08/35] feat: revamp server login system --- app/(tabs)/hosts.tsx | 13 + app/(tabs)/sessions.tsx | 13 + app/AppContext.tsx | 112 ++- app/_layout.tsx | 45 +- app/authentication/AuthFlow.tsx | 1177 ++++++++++++++++++++++++++ app/authentication/LoginForm.tsx | 469 ---------- app/authentication/ServerForm.tsx | 136 --- app/components/ConnectEmptyState.tsx | 43 + app/main-axios.ts | 84 +- app/tabs/settings/Settings.tsx | 133 ++- 10 files changed, 1512 insertions(+), 713 deletions(-) create mode 100644 app/authentication/AuthFlow.tsx delete mode 100644 app/authentication/LoginForm.tsx delete mode 100644 app/authentication/ServerForm.tsx create mode 100644 app/components/ConnectEmptyState.tsx diff --git a/app/(tabs)/hosts.tsx b/app/(tabs)/hosts.tsx index fff8fde..95c8568 100644 --- a/app/(tabs)/hosts.tsx +++ b/app/(tabs)/hosts.tsx @@ -1,5 +1,18 @@ import Hosts from "@/app/tabs/hosts/Hosts"; +import { Screen } from "@/app/components/Screen"; +import { ConnectEmptyState } from "@/app/components/ConnectEmptyState"; +import { useAppContext } from "@/app/AppContext"; export default function HostsScreen() { + const { isAuthenticated } = useAppContext(); + + if (!isAuthenticated) { + return ( + + + + ); + } + return ; } diff --git a/app/(tabs)/sessions.tsx b/app/(tabs)/sessions.tsx index 1f964ca..8c44b29 100644 --- a/app/(tabs)/sessions.tsx +++ b/app/(tabs)/sessions.tsx @@ -1,5 +1,18 @@ import Sessions from "@/app/tabs/sessions/Sessions"; +import { Screen } from "@/app/components/Screen"; +import { ConnectEmptyState } from "@/app/components/ConnectEmptyState"; +import { useAppContext } from "@/app/AppContext"; export default function SessionsScreen() { + const { isAuthenticated } = useAppContext(); + + if (!isAuthenticated) { + return ( + + + + ); + } + return ; } diff --git a/app/AppContext.tsx b/app/AppContext.tsx index a4beb5a..2969b1c 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -5,6 +5,7 @@ import React, { ReactNode, useEffect, useRef, + useCallback, } from "react"; import { AppState, AppStateStatus } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; @@ -13,6 +14,7 @@ import { initializeServerConfig, getLatestGitHubRelease, setAuthStateCallback, + getCurrentServerUrl, } from "./main-axios"; import Constants from "expo-constants"; @@ -21,11 +23,10 @@ interface Server { ip: string; } +/** Steps the auth flow can be opened directly to. */ +export type AuthStep = "server" | "login" | "signup"; + interface AppContextType { - showServerManager: boolean; - setShowServerManager: (show: boolean) => void; - showLoginForm: boolean; - setShowLoginForm: (show: boolean) => void; selectedServer: Server | null; setSelectedServer: (server: Server | null) => void; isAuthenticated: boolean; @@ -34,6 +35,16 @@ interface AppContextType { setShowUpdateScreen: (show: boolean) => void; isLoading: boolean; setIsLoading: (loading: boolean) => void; + + /** Whether a server URL is currently configured (drives empty states). */ + hasServerConfigured: boolean; + setHasServerConfigured: (has: boolean) => void; + + /** Auth flow overlay control. */ + authFlowVisible: boolean; + authFlowInitialStep: AuthStep; + openAuthFlow: (step?: AuthStep) => void; + closeAuthFlow: () => void; } const AppContext = createContext(undefined); @@ -51,13 +62,24 @@ interface AppProviderProps { } export const AppProvider: React.FC = ({ children }) => { - const [showServerManager, setShowServerManager] = useState(false); - const [showLoginForm, setShowLoginForm] = useState(false); const [selectedServer, setSelectedServer] = useState(null); const [isAuthenticated, setAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [showUpdateScreen, setShowUpdateScreen] = useState(false); + const [hasServerConfigured, setHasServerConfigured] = useState(false); + + const [authFlowVisible, setAuthFlowVisible] = useState(false); + const [authFlowInitialStep, setAuthFlowInitialStep] = + useState("server"); + + const openAuthFlow = useCallback((step: AuthStep = "server") => { + setAuthFlowInitialStep(step); + setAuthFlowVisible(true); + }, []); + + const closeAuthFlow = useCallback(() => { + setAuthFlowVisible(false); + }, []); const checkShouldShowUpdateScreen = async (): Promise => { try { @@ -97,12 +119,15 @@ export const AppProvider: React.FC = ({ children }) => { const serverConfig = await AsyncStorage.getItem("serverConfig"); const legacyServer = await AsyncStorage.getItem("server"); - const version = await getVersionInfo(); + await getVersionInfo(); const shouldShowUpdateScreen = await checkShouldShowUpdateScreen(); setShowUpdateScreen(shouldShowUpdateScreen); - if (serverConfig || legacyServer) { + const serverConfigured = !!(serverConfig || legacyServer); + setHasServerConfigured(serverConfigured); + + if (serverConfigured) { let authStatus = false; const jwtToken = await AsyncStorage.getItem("jwt"); @@ -111,67 +136,49 @@ export const AppProvider: React.FC = ({ children }) => { try { const { getUserInfo } = await import("./main-axios"); const meRes = await getUserInfo(); - if (meRes && meRes.username) { - if (meRes.data_unlocked === false) { - authStatus = false; - } else { - authStatus = true; - } - } else { + if (meRes && meRes.username && meRes.data_unlocked !== false) { + authStatus = true; } } catch (e) { console.error("[AppContext] Auto-login failed:", e); authStatus = false; await AsyncStorage.removeItem("jwt"); } - } else { } - let serverInfo = null; + let serverInfo: Server | null = null; if (legacyServer) { serverInfo = JSON.parse(legacyServer); } else if (serverConfig) { const config = JSON.parse(serverConfig); - serverInfo = { - name: "Server", - ip: config.serverUrl, - }; + serverInfo = { name: "Server", ip: config.serverUrl }; } + setSelectedServer(serverInfo); - if (authStatus) { - setAuthenticated(true); - setShowServerManager(false); - setShowLoginForm(false); - setSelectedServer(serverInfo); - } else { - setAuthenticated(false); - setShowServerManager(false); - setShowLoginForm(true); - setSelectedServer(serverInfo); - } + setAuthenticated(authStatus); + // A configured-but-unauthenticated server lands the user on the + // empty-state shell; they re-open the auth flow themselves. } else { + // Brand-new install: guide the user, but the flow is dismissible. setAuthenticated(false); - setShowServerManager(true); - setShowLoginForm(false); + openAuthFlow("server"); } } catch (error) { setAuthenticated(false); - setShowServerManager(true); - setShowLoginForm(false); } finally { setIsLoading(false); } }; initializeApp(); - }, []); + }, [openAuthFlow]); useEffect(() => { - setAuthStateCallback(async (isAuthenticated: boolean) => { - if (!isAuthenticated) { + setAuthStateCallback((authed: boolean) => { + if (!authed) { + // Token expired / 401: drop to the empty-state shell. Tabs render + // their "no server connected" prompt; the user re-authenticates. setAuthenticated(false); - setShowLoginForm(true); - setShowServerManager(false); } }); }, []); @@ -205,8 +212,11 @@ export const AppProvider: React.FC = ({ children }) => { !userInfo.username || userInfo.data_unlocked === false ) { + setAuthenticated(false); } } catch (error) { + // Network blips shouldn't log the user out; the 401 callback handles + // genuine auth failures. } finally { validationInProgressRef.current = false; } @@ -223,13 +233,17 @@ export const AppProvider: React.FC = ({ children }) => { }; }, [isAuthenticated]); + // Keep hasServerConfigured in sync whenever the auth flow closes (the user + // may have just added or changed a server inside it). + useEffect(() => { + if (!authFlowVisible) { + setHasServerConfigured(!!getCurrentServerUrl()); + } + }, [authFlowVisible]); + return ( = ({ children }) => { setShowUpdateScreen, isLoading, setIsLoading, + hasServerConfigured, + setHasServerConfigured, + authFlowVisible, + authFlowInitialStep, + openAuthFlow, + closeAuthFlow, }} > {children} diff --git a/app/_layout.tsx b/app/_layout.tsx index a0695dc..82cc854 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,8 +7,7 @@ import { KeyboardCustomizationProvider } from "./contexts/KeyboardCustomizationC import { ThemeProvider, useTheme, useThemeColor } from "./contexts/ThemeContext"; import { AppLockProvider, useAppLock } from "./contexts/AppLockContext"; import { LockScreen } from "@/app/components/LockScreen"; -import ServerForm from "@/app/authentication/ServerForm"; -import LoginForm from "@/app/authentication/LoginForm"; +import AuthFlow from "@/app/authentication/AuthFlow"; import { View, Text, ActivityIndicator, TouchableOpacity } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { StatusBar } from "expo-status-bar"; @@ -21,11 +20,8 @@ import UpdateRequired from "@/app/authentication/UpdateRequired"; function RootLayoutContent() { const { - showServerManager, - setShowServerManager, - showLoginForm, - setShowLoginForm, - isAuthenticated, + authFlowVisible, + openAuthFlow, showUpdateScreen, isLoading, setIsLoading, @@ -45,8 +41,7 @@ function RootLayoutContent() { { setIsLoading(false); - setShowLoginForm(false); - setShowServerManager(true); + openAuthFlow("server"); }} className="mt-6 px-6 py-3 bg-card border border-border" > @@ -62,22 +57,24 @@ function RootLayoutContent() { } if (showUpdateScreen) return ; - if (showServerManager) return ; - if (showLoginForm) return ; - if (isAuthenticated) { - return ( - - - - - - - - ); - } - - return ; + // The tab shell always renders once loaded. When the user isn't connected, + // the tabs themselves show a "no server connected" empty state. The auth flow + // is layered on top as a dismissible full-screen overlay. + return ( + + + + + + + {authFlowVisible ? ( + + + + ) : null} + + ); } function AppLockGate() { diff --git a/app/authentication/AuthFlow.tsx b/app/authentication/AuthFlow.tsx new file mode 100644 index 0000000..71288a5 --- /dev/null +++ b/app/authentication/AuthFlow.tsx @@ -0,0 +1,1177 @@ +import { + View, + ScrollView, + KeyboardAvoidingView, + Platform, + TouchableOpacity, + Alert, + ActivityIndicator, +} from "react-native"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + Server, + ShieldAlert, + ArrowLeft, + Eye, + EyeOff, + KeyRound, + User as UserIcon, + RefreshCw, + Globe, + X, +} from "lucide-react-native"; +import { WebView, WebViewNavigation } from "react-native-webview"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Text, Input, Button, Label } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { useAppContext } from "../AppContext"; +import { + saveServerConfig, + getCurrentServerUrl, + initializeServerConfig, + setCookie, + getUserInfo, + loginUser, + registerUser, + verifyTOTPLogin, + initiatePasswordReset, + verifyPasswordResetCode, + completePasswordReset, + getSetupRequired, + getRegistrationAllowed, + getPasswordLoginAllowed, + getOIDCConfig, + getOIDCAuthorizeUrl, + isReverseProxyAuthGate, +} from "../main-axios"; + +type Step = + | "server" + | "login" + | "totp" + | "signup" + | "reset" + | "oidc"; + +/** Server capabilities probed after the server URL is set. */ +interface ServerCaps { + setupRequired: boolean; + passwordLoginAllowed: boolean; + registrationAllowed: boolean; + oidcAvailable: boolean; +} + +function errMessage(e: any, fallback: string): string { + return e?.response?.data?.error || e?.message || fallback; +} + +export default function AuthFlow() { + const { authFlowInitialStep, closeAuthFlow, setAuthenticated, setSelectedServer } = + useAppContext(); + const insets = useSafeAreaInsets(); + const color = useThemeColor(); + const bg = color("background") ?? "#0c0d0b"; + const accent = color("accent-brand") ?? "#f59145"; + const muted = color("muted-foreground"); + + const [step, setStep] = useState(authFlowInitialStep as Step); + const [serverUrl, setServerUrl] = useState(""); + const [caps, setCaps] = useState(null); + const [busy, setBusy] = useState(false); + // Carries the TOTP temp token between the login and totp steps. + const totpTempTokenRef = useRef(""); + + // Hostname shown in the shared header. + const activeHost = (getCurrentServerUrl() ?? serverUrl).replace( + /^https?:\/\//, + "", + ); + + useEffect(() => { + const current = getCurrentServerUrl(); + if (current) setServerUrl(current); + }, []); + + const finishAuthenticated = useCallback(async () => { + try { + await initializeServerConfig(); + } catch {} + const url = getCurrentServerUrl(); + if (url) setSelectedServer({ name: "Server", ip: url }); + setAuthenticated(true); + closeAuthFlow(); + }, [closeAuthFlow, setAuthenticated, setSelectedServer]); + + // ── Step: server ──────────────────────────────────────────────────────── + + /** + * Probes the currently-configured server to decide which sign-in affordances + * to show, then advances to the appropriate step. Returns false on failure + * (server unreachable) so callers can react. + */ + const probeServer = useCallback(async (): Promise => { + // If the server sits behind a reverse-proxy auth gate (Cloudflare Access, + // Authelia, …), API endpoints return the proxy's HTML login page rather + // than JSON — a native form can't work. Send the user to the browser-based + // external sign-in instead. + if (await isReverseProxyAuthGate()) { + setCaps({ + setupRequired: false, + passwordLoginAllowed: false, + registrationAllowed: false, + oidcAvailable: false, + }); + setStep("oidc"); + return true; + } + + const [setupRes, pwRes, regRes, oidcRes] = await Promise.allSettled([ + getSetupRequired(), + getPasswordLoginAllowed(), + getRegistrationAllowed(), + getOIDCConfig(), + ]); + + // If every probe failed, the server is unreachable — surface that rather + // than rendering an empty login form. + if ( + setupRes.status === "rejected" && + pwRes.status === "rejected" && + regRes.status === "rejected" && + oidcRes.status === "rejected" + ) { + return false; + } + + const setupRequired = + setupRes.status === "fulfilled" && !!setupRes.value?.setup_required; + const passwordLoginAllowed = + pwRes.status === "fulfilled" ? pwRes.value?.allowed !== false : true; + const registrationAllowed = + regRes.status === "fulfilled" && !!regRes.value?.allowed; + const oidcAvailable = + oidcRes.status === "fulfilled" && !!oidcRes.value?.client_id; + + setCaps({ + setupRequired, + passwordLoginAllowed, + registrationAllowed, + oidcAvailable, + }); + + setStep(setupRequired ? "signup" : "login"); + return true; + }, []); + + const handleConnect = async () => { + const url = serverUrl.trim().replace(/\/$/, ""); + if (!url) { + toast.error("Please enter a server address"); + return; + } + if (!/^https?:\/\//.test(url)) { + toast.error("Server address must start with http:// or https://"); + return; + } + + setBusy(true); + try { + await saveServerConfig({ + serverUrl: url, + lastUpdated: new Date().toISOString(), + }); + setSelectedServer({ name: "Server", ip: url }); + + const ok = await probeServer(); + if (!ok) { + toast.error("Could not reach that server"); + } + } catch (e: any) { + toast.error(errMessage(e, "Could not reach that server")); + } finally { + setBusy(false); + } + }; + + // When the flow is opened directly to login/signup (e.g. "Sign in" after + // logout, with a server already configured), the server hasn't been probed + // yet so `caps` is null and no form would render. Probe on mount in that case. + useEffect(() => { + if ( + (authFlowInitialStep === "login" || authFlowInitialStep === "signup") && + getCurrentServerUrl() + ) { + setBusy(true); + probeServer() + .then((ok) => { + if (!ok) { + toast.error("Could not reach that server"); + setStep("server"); + } + }) + .catch(() => setStep("server")) + .finally(() => setBusy(false)); + } + // Run once on mount for the initial step. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const goToServer = () => { + setCaps(null); + setStep("server"); + }; + + // ── Render ─────────────────────────────────────────────────────────────── + return ( + + {step === "oidc" ? ( + + // If there's a usable native login step, return to it; otherwise + // (pure reverse-proxy case) go back to the server step. + setStep( + caps && (caps.passwordLoginAllowed || caps.oidcAvailable) + ? "login" + : "server", + ) + } + onAuthenticated={finishAuthenticated} + /> + ) : ( + + {/* Shared header */} + + {step !== "server" ? ( + + + + {activeHost || "Server"} + + + ) : ( + + )} + + + + + + + + {/* Brand mark */} + + + + + TERMIX + + + {step === "server" + ? "CONNECT TO YOUR SERVER" + : (activeHost || "").toUpperCase()} + + + {step === "server" && ( + + )} + + {step === "login" && caps && ( + { + totpTempTokenRef.current = tempToken; + setStep("totp"); + }} + onAuthenticated={finishAuthenticated} + onForgot={() => setStep("reset")} + onSignup={() => setStep("signup")} + onOidc={() => setStep("oidc")} + /> + )} + + {/* Probe in flight (e.g. opened directly to login after logout). */} + {step === "login" && !caps && ( + + + + Connecting… + + + )} + + {step === "totp" && ( + totpTempTokenRef.current} + onAuthenticated={finishAuthenticated} + onBack={() => setStep("login")} + /> + )} + + {step === "signup" && ( + setStep(caps?.setupRequired ? "server" : "login")} + /> + )} + + {step === "reset" && ( + setStep("login")} + onBack={() => setStep("login")} + /> + )} + + + + )} + + ); +} + +// ── Sub-steps ─────────────────────────────────────────────────────────────── + +function ServerStep({ + serverUrl, + setServerUrl, + busy, + onConnect, + color, +}: { + serverUrl: string; + setServerUrl: (v: string) => void; + busy: boolean; + onConnect: () => void; + color: ReturnType; +}) { + return ( + <> + + + + } + onSubmitEditing={onConnect} + returnKeyType="go" + /> + + + Enter the address of your self-hosted Termix server, including + http:// or https://. + + + + + + + + Using a self-signed certificate? Install its root CA on your device + first. Local HTTP servers are supported. + + + + ); +} + +function PasswordInput({ + value, + onChangeText, + placeholder, + editable, + onSubmitEditing, + color, +}: { + value: string; + onChangeText: (v: string) => void; + placeholder: string; + editable: boolean; + onSubmitEditing?: () => void; + color: ReturnType; +}) { + const [show, setShow] = useState(false); + return ( + } + trailing={ + setShow((s) => !s)} hitSlop={8}> + {show ? ( + + ) : ( + + )} + + } + /> + ); +} + +function LoginStep({ + caps, + color, + onTotp, + onAuthenticated, + onForgot, + onSignup, + onOidc, +}: { + caps: ServerCaps; + color: ReturnType; + onTotp: (tempToken: string) => void; + onAuthenticated: () => void; + onForgot: () => void; + onSignup: () => void; + onOidc: () => void; +}) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + + const handleLogin = async () => { + if (!username.trim() || !password) { + toast.error("Enter your username and password"); + return; + } + setBusy(true); + try { + const res = await loginUser(username.trim(), password); + if (res?.requires_totp) { + onTotp(res.temp_token || res.token || ""); + return; + } + // loginUser already persisted the JWT. + await onAuthenticated(); + } catch (e: any) { + if (e?.code === "PROXY_AUTH_GATE") { + // The server is behind a login proxy — fall back to the browser flow. + toast.error("This server uses a login proxy — opening external sign-in"); + onOidc(); + return; + } + toast.error(errMessage(e, "Login failed")); + } finally { + setBusy(false); + } + }; + + const showPasswordCard = caps.passwordLoginAllowed; + + return ( + + {showPasswordCard ? ( + + + + } + /> + + + + + + + + + + + + Forgot password? + + + {caps.registrationAllowed ? ( + + Sign up + + ) : null} + + + ) : ( + + + Password login is disabled on this server. Use external sign-in + below. + + + )} + + {caps.oidcAvailable ? ( + <> + {showPasswordCard ? ( + + + + OR + + + + ) : ( + + )} + + + ) : null} + + {!showPasswordCard && !caps.oidcAvailable ? ( + + + This server has no available sign-in methods (password login is + disabled and no SSO provider is configured). Check the server + configuration. + + + ) : null} + + ); +} + +function TotpStep({ + color, + getTempToken, + onAuthenticated, + onBack, +}: { + color: ReturnType; + getTempToken: () => string; + onAuthenticated: () => void; + onBack: () => void; +}) { + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + + const handleVerify = async () => { + const c = code.trim(); + if (!c) { + toast.error("Enter your authentication code"); + return; + } + setBusy(true); + try { + await verifyTOTPLogin(getTempToken(), c); + await onAuthenticated(); + } catch (e: any) { + toast.error(errMessage(e, "Invalid code")); + } finally { + setBusy(false); + } + }; + + return ( + + + + } + /> + + + Enter the 6-digit code from your authenticator app, or one of your + backup codes. + + + + Back to sign in + + + ); +} + +function SignupStep({ + color, + firstUser, + onAuthenticated, + onBack, +}: { + color: ReturnType; + firstUser: boolean; + onAuthenticated: () => void; + onBack: () => void; +}) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + + const handleSignup = async () => { + if (!username.trim() || !password) { + toast.error("Enter a username and password"); + return; + } + if (password.length < 6) { + toast.error("Password must be at least 6 characters"); + return; + } + if (password !== confirm) { + toast.error("Passwords do not match"); + return; + } + setBusy(true); + try { + await registerUser(username.trim(), password); + // Auto sign-in after creating the account. + await loginUser(username.trim(), password); + await onAuthenticated(); + } catch (e: any) { + toast.error(errMessage(e, "Could not create account")); + } finally { + setBusy(false); + } + }; + + return ( + + + {firstUser ? ( + + This is the first account on the server and will be an administrator. + + ) : ( + + )} + + + } + /> + + + + + + + + + + + + {firstUser ? "Back" : "Already have an account? Sign in"} + + + + ); +} + +function ResetStep({ + color, + onDone, + onBack, +}: { + color: ReturnType; + onDone: () => void; + onBack: () => void; +}) { + const [phase, setPhase] = useState<"request" | "code" | "password">("request"); + const [username, setUsername] = useState(""); + const [resetCode, setResetCode] = useState(""); + const [tempToken, setTempToken] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [busy, setBusy] = useState(false); + + const requestCode = async () => { + if (!username.trim()) { + toast.error("Enter your username"); + return; + } + setBusy(true); + try { + await initiatePasswordReset(username.trim()); + toast.success("Reset code generated — check the server logs"); + setPhase("code"); + } catch (e: any) { + toast.error(errMessage(e, "Could not start password reset")); + } finally { + setBusy(false); + } + }; + + const verifyCode = async () => { + if (!resetCode.trim()) { + toast.error("Enter the reset code"); + return; + } + setBusy(true); + try { + const res = await verifyPasswordResetCode(username.trim(), resetCode.trim()); + setTempToken(res?.tempToken || ""); + setPhase("password"); + } catch (e: any) { + toast.error(errMessage(e, "Invalid reset code")); + } finally { + setBusy(false); + } + }; + + const setNew = async () => { + if (newPassword.length < 6) { + toast.error("Password must be at least 6 characters"); + return; + } + setBusy(true); + try { + await completePasswordReset(username.trim(), tempToken, newPassword); + toast.success("Password reset — you can sign in now"); + onDone(); + } catch (e: any) { + toast.error(errMessage(e, "Could not reset password")); + } finally { + setBusy(false); + } + }; + + return ( + + + + {phase === "request" && ( + <> + + Enter your username. A reset code will be generated and printed to + the server's logs. + + } + /> + + + )} + + {phase === "code" && ( + <> + + Enter the 6-digit reset code from the server logs. + + } + /> + + + )} + + {phase === "password" && ( + <> + + Choose a new password. + + + + + )} + + + Back to sign in + + + ); +} + +// ── OIDC / reverse-proxy WebView fallback ──────────────────────────────────── +// This is the ONLY surviving WebView. It loads the server's OIDC authorize URL +// (or, as a fallback, the server root) so external SSO providers and +// reverse-proxy login forms (Cloudflare Access, Authelia, …) work. The injected +// JS captures the JWT once login completes and posts it back to native. + +function OidcStep({ + bg, + accent, + onBack, + onAuthenticated, +}: { + bg: string; + accent: string; + onBack: () => void; + onAuthenticated: () => void; +}) { + const color = useThemeColor(); + const webViewRef = useRef(null); + const [source, setSource] = useState<{ uri: string } | null>(null); + const [url, setUrl] = useState(""); + const [authenticating, setAuthenticating] = useState(false); + const [webViewKey, setWebViewKey] = useState(() => String(Date.now())); + + useEffect(() => { + const init = async () => { + let uri: string | null = null; + try { + const res = await getOIDCAuthorizeUrl(); + uri = res?.auth_url || null; + } catch { + // ignore — fall back to the server root, which renders the web login + // (covers reverse-proxy login forms). + } + if (!uri) uri = getCurrentServerUrl(); + if (uri) { + setSource({ uri }); + setUrl(uri); + } + }; + init(); + }, []); + + const handleNav = (navState: WebViewNavigation) => { + if (!navState.loading) setUrl(navState.url); + }; + + const handleError = (syntheticEvent: any) => { + const { nativeEvent } = syntheticEvent; + if ( + nativeEvent.description?.includes("SSL") || + nativeEvent.description?.includes("certificate") || + nativeEvent.description?.includes("ERR_CERT") + ) { + Alert.alert( + "SSL Certificate Error", + "Unable to verify the server's SSL certificate. Install your self-signed certificate's root CA on the device (as a CA certificate) and rebuild the app.\n\nError: " + + (nativeEvent.description || "Unknown SSL error"), + [{ text: "OK" }], + ); + } + }; + + const onMessage = async (event: any) => { + if (authenticating) return; + try { + const data = JSON.parse(event.nativeEvent.data); + if (data.type === "AUTH_SUCCESS" && data.token) { + setAuthenticating(true); + await setCookie("jwt", data.token); + const saved = await AsyncStorage.getItem("jwt"); + if (!saved) { + setAuthenticating(false); + Alert.alert("Error", "Failed to save authentication token."); + return; + } + await initializeServerConfig(); + // Confirm the token is actually valid before declaring success. + try { + const me = await getUserInfo(); + if (!me?.username) { + setAuthenticating(false); + return; + } + } catch { + setAuthenticating(false); + return; + } + await onAuthenticated(); + } + } catch { + setAuthenticating(false); + } + }; + + const injectedJavaScript = ` + (function() { + const isCallback = window.location.href.includes('/oidc/callback') || + window.location.href.includes('?success=') || + window.location.href.includes('?error='); + + let hasNotified = false; + let initialCheckComplete = false; + + const notifyAuth = (token) => { + if (hasNotified || !token || token.length < 20) return; + if (!isCallback && !initialCheckComplete) return; + hasNotified = true; + try { + window.ReactNativeWebView && window.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'AUTH_SUCCESS', token: token }) + ); + } catch (e) {} + }; + + const checkAuth = () => { + try { + const ls = localStorage.getItem('jwt'); + if (ls) { notifyAuth(ls); return true; } + const ss = sessionStorage.getItem('jwt'); + if (ss) { notifyAuth(ss); return true; } + const m = document.cookie.split('; ').find(r => r.startsWith('jwt=')); + if (m) { notifyAuth(m.split('=')[1]); return true; } + } catch (e) {} + return false; + }; + + const origSet = localStorage.setItem; + localStorage.setItem = function(k, v) { + origSet.apply(this, arguments); + if (k === 'jwt' && v && !hasNotified) checkAuth(); + }; + + const id = setInterval(() => { + if (hasNotified) { clearInterval(id); return; } + if (checkAuth()) clearInterval(id); + }, 500); + checkAuth(); + setTimeout(() => { initialCheckComplete = true; }, 1000); + setTimeout(() => clearInterval(id), 120000); + })(); + true; + `; + + return ( + + + + + + Sign in + + + + + {url.replace(/^https?:\/\//, "")} + + + { + setWebViewKey(String(Date.now())); + webViewRef.current?.reload(); + }} + hitSlop={8} + className="py-1 pl-2" + > + + + + + {source ? ( + ( + + + + )} + /> + ) : ( + + + + )} + + ); +} diff --git a/app/authentication/LoginForm.tsx b/app/authentication/LoginForm.tsx deleted file mode 100644 index 587b9da..0000000 --- a/app/authentication/LoginForm.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import { View, TouchableOpacity, Alert, ActivityIndicator, Platform } from "react-native"; -import { useAppContext } from "../AppContext"; -import { useState, useEffect, useRef } from "react"; -import { - setCookie, - getCurrentServerUrl, - initializeServerConfig, -} from "../main-axios"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ArrowLeft, RefreshCw } from "lucide-react-native"; -import { WebView, WebViewNavigation } from "react-native-webview"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { Text } from "@/app/components/ui"; -import { useThemeColor } from "@/app/contexts/ThemeContext"; - -export default function LoginForm() { - const { - setAuthenticated, - setShowLoginForm, - setShowServerManager, - selectedServer, - } = useAppContext(); - const insets = useSafeAreaInsets(); - const color = useThemeColor(); - const bg = color("background") ?? "#0c0d0b"; - const accent = color("accent-brand") ?? "#f59145"; - const webViewRef = useRef(null); - const [url, setUrl] = useState(""); - const [canGoBack, setCanGoBack] = useState(false); - const [loading, setLoading] = useState(true); - const [source, setSource] = useState<{ uri: string }>({ uri: "" }); - const [webViewKey, setWebViewKey] = useState(() => String(Date.now())); - const [hasNavigated, setHasNavigated] = useState(false); - - useEffect(() => { - const initializeLogin = async () => { - const existingToken = await AsyncStorage.getItem("jwt"); - if (existingToken) { - try { - const { getUserInfo } = await import("../main-axios"); - const userInfo = await getUserInfo(); - - if (userInfo && userInfo.username) { - if (userInfo.data_unlocked === false) { - } else { - setAuthenticated(true); - setShowLoginForm(false); - return; - } - } - } catch (error) {} - } - - setWebViewKey(String(Date.now())); - - const serverUrl = getCurrentServerUrl(); - if (serverUrl) { - setSource({ uri: serverUrl }); - setUrl(serverUrl); - } else if (selectedServer?.ip) { - setSource({ uri: selectedServer.ip }); - setUrl(selectedServer.ip); - } - }; - - initializeLogin(); - }, [selectedServer]); - - const handleBackToServerConfig = () => { - setShowLoginForm(false); - setShowServerManager(true); - }; - - const handleRefresh = () => { - webViewRef.current?.reload(); - }; - - const handleNavigationStateChange = (navState: WebViewNavigation) => { - setCanGoBack(navState.canGoBack); - setLoading(navState.loading); - setHasNavigated(true); - if (!navState.loading) { - setUrl(navState.url); - } - }; - - const handleError = (syntheticEvent: any) => { - const { nativeEvent } = syntheticEvent; - console.error("[LoginForm] WebView error:", nativeEvent); - - if ( - nativeEvent.description?.includes("SSL") || - nativeEvent.description?.includes("certificate") || - nativeEvent.description?.includes("ERR_CERT") - ) { - Alert.alert( - "SSL Certificate Error", - "Unable to verify the server's SSL certificate. Please ensure:\n\n" + - "1. Your self-signed certificate's root CA is installed in Android Settings > Security > Encryption & Credentials > Install a certificate\n" + - "2. The certificate is installed as a 'CA certificate'\n" + - "3. You've rebuilt the app after installing the certificate\n\n" + - "Error: " + - (nativeEvent.description || "Unknown SSL error"), - [{ text: "OK" }], - ); - } - }; - - const handleHttpError = (syntheticEvent: any) => { - const { nativeEvent } = syntheticEvent; - console.warn( - "[LoginForm] HTTP error:", - nativeEvent.statusCode, - nativeEvent.url, - ); - }; - - const [isAuthenticating, setIsAuthenticating] = useState(false); - - const onMessage = async (event: any) => { - if (isAuthenticating) { - return; - } - - try { - const data = JSON.parse(event.nativeEvent.data); - - if (data.type === "AUTH_SUCCESS" && data.token) { - setIsAuthenticating(true); - - try { - const tokenParts = data.token.split("."); - if (tokenParts.length === 3) { - const payload = JSON.parse(atob(tokenParts[1])); - if (payload.exp) { - const expirationDate = new Date(payload.exp * 1000); - const now = new Date(); - const daysUntilExpiration = Math.floor( - (expirationDate.getTime() - now.getTime()) / - (1000 * 60 * 60 * 24), - ); - } - } - } catch (jwtParseError) { - console.error( - "[LoginForm] Failed to parse JWT for diagnostics:", - jwtParseError, - ); - } - - await setCookie("jwt", data.token); - - const savedToken = await AsyncStorage.getItem("jwt"); - if (!savedToken) { - setIsAuthenticating(false); - Alert.alert( - "Error", - "Failed to save authentication token. Please try again.", - ); - return; - } - - await initializeServerConfig(); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - setAuthenticated(true); - setShowLoginForm(false); - } - } catch (error) { - console.error("[LoginForm] Error processing auth token:", error); - setIsAuthenticating(false); - Alert.alert("Error", "Failed to process authentication token."); - } - }; - - const injectedJavaScript = ` - (function() { - const isOIDCCallback = window.location.href.includes('/oidc/callback') || - window.location.href.includes('?success=') || - window.location.href.includes('?error='); - - if (!isOIDCCallback) { - try { - if (typeof localStorage !== 'undefined') { - localStorage.removeItem('jwt'); - } - if (typeof sessionStorage !== 'undefined') { - sessionStorage.removeItem('jwt'); - } - - const cookies = document.cookie.split(";"); - cookies.forEach(function(c) { - const cookieName = c.split("=")[0].trim(); - if (cookieName === 'jwt') { - document.cookie = cookieName + "=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;"; - document.cookie = cookieName + "=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;domain=" + window.location.hostname; - } - }); - } catch(e) { - console.error('[LoginForm] Error clearing JWT from WebView:', e); - } - } - - const style = document.createElement('style'); - style.textContent = \` - button:has-text("Install Mobile App"), - [class*="mobile-app"], - [class*="install-app"], - [id*="mobile-app"], - [id*="install-app"], - a[href*="app-store"], - a[href*="play-store"], - a[href*="google.com/store"], - a[href*="apple.com/app"], - button[aria-label*="Install"], - button[aria-label*="Mobile App"], - button[aria-label*="Download App"], - a[aria-label*="Install"], - a[aria-label*="Mobile App"], - a[aria-label*="Download App"] { - display: none !important; - } - \`; - document.head.appendChild(style); - - const hideByText = () => { - const buttons = document.querySelectorAll('button, a'); - buttons.forEach(btn => { - const text = btn.textContent?.toLowerCase() || ''; - if (text.includes('install') && text.includes('mobile')) { - btn.style.display = 'none'; - } - if (text.includes('download') && text.includes('app')) { - btn.style.display = 'none'; - } - if (text.includes('get') && text.includes('app')) { - btn.style.display = 'none'; - } - }); - }; - - hideByText(); - setTimeout(hideByText, 500); - setTimeout(hideByText, 1000); - setTimeout(hideByText, 2000); - - const observer = new MutationObserver(hideByText); - observer.observe(document.body, { childList: true, subtree: true }); - - let hasNotified = false; - let lastCheckedToken = null; - let initialCheckComplete = false; - - const notifyAuth = (token, source) => { - if (hasNotified || !token || token === lastCheckedToken) { - return; - } - - if (isOIDCCallback) { - hasNotified = true; - lastCheckedToken = token; - } - else if (initialCheckComplete) { - hasNotified = true; - lastCheckedToken = token; - } else { - return; - } - - if (!hasNotified) return; - - try { - const message = JSON.stringify({ - type: 'AUTH_SUCCESS', - token: token, - source: source, - timestamp: Date.now() - }); - - if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { - window.ReactNativeWebView.postMessage(message); - } else { - console.error('[WebView] ReactNativeWebView.postMessage not available!'); - } - } catch (e) { - console.error('[WebView] Error sending message:', e); - } - }; - - const checkAuth = () => { - try { - const localToken = localStorage.getItem('jwt'); - if (localToken && localToken.length > 20) { - notifyAuth(localToken, 'localStorage'); - return true; - } - - const sessionToken = sessionStorage.getItem('jwt'); - if (sessionToken && sessionToken.length > 20) { - notifyAuth(sessionToken, 'sessionStorage'); - return true; - } - - const cookies = document.cookie; - if (cookies && cookies.length > 0) { - const cookieArray = cookies.split('; '); - const tokenCookie = cookieArray.find(row => row.startsWith('jwt=')); - - if (tokenCookie) { - const token = tokenCookie.split('=')[1]; - if (token && token.length > 20) { - notifyAuth(token, 'cookie'); - return true; - } - } - } - } catch (error) { - console.error('[WebView] Error in checkAuth:', error); - } - return false; - }; - - const originalSetItem = localStorage.setItem; - localStorage.setItem = function(key, value) { - originalSetItem.apply(this, arguments); - if (key === 'jwt' && value && value.length > 20 && !hasNotified) { - checkAuth(); - } - }; - - const originalSessionSetItem = sessionStorage.setItem; - sessionStorage.setItem = function(key, value) { - originalSessionSetItem.apply(this, arguments); - if (key === 'jwt' && value && value.length > 20 && !hasNotified) { - checkAuth(); - } - }; - - const intervalId = setInterval(() => { - if (hasNotified) { - clearInterval(intervalId); - return; - } - if (checkAuth()) { - clearInterval(intervalId); - } - }, 500); - - checkAuth(); - - setTimeout(() => { - initialCheckComplete = true; - }, 1000); - - window.addEventListener('message', (event) => { - try { - if (event.data && typeof event.data === 'object') { - const data = event.data; - if (data.type === 'AUTH_SUCCESS' && data.token && data.source === 'explicit') { - notifyAuth(data.token, 'explicit-message'); - } - } - } catch (e) { - console.error('[WebView] Error processing message event:', e); - } - }); - - document.addEventListener('visibilitychange', () => { - if (!document.hidden && !hasNotified) { - checkAuth(); - } - }); - - setTimeout(() => { - clearInterval(intervalId); - }, 120000); - })(); - `; - - if (!source.uri) { - return ( - - - - Loading server configuration… - - - ); - } - - return ( - - - - - - Server - - - - - {url.replace(/^https?:\/\//, "")} - - - - - - - - ( - - - - )} - /> - - ); -} diff --git a/app/authentication/ServerForm.tsx b/app/authentication/ServerForm.tsx deleted file mode 100644 index 5bdaa50..0000000 --- a/app/authentication/ServerForm.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { - View, - ScrollView, - KeyboardAvoidingView, - Platform, -} from "react-native"; -import { useAppContext } from "../AppContext"; -import { useState, useEffect } from "react"; -import { saveServerConfig, getCurrentServerUrl } from "../main-axios"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Server, ShieldAlert } from "lucide-react-native"; -import { Text, Input, Button, Label } from "@/app/components/ui"; -import { useThemeColor } from "@/app/contexts/ThemeContext"; -import { toast } from "@/app/utils/toast"; - -export default function ServerForm() { - const { - setShowServerManager, - setShowLoginForm, - setSelectedServer, - selectedServer, - } = useAppContext(); - const insets = useSafeAreaInsets(); - const color = useThemeColor(); - const [serverUrl, setServerUrl] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const current = getCurrentServerUrl(); - if (current) setServerUrl(current); - else if (selectedServer?.ip) setServerUrl(selectedServer.ip); - }, [selectedServer]); - - const handleConnect = async () => { - const url = serverUrl.trim(); - if (!url) { - toast.error("Please enter a server address"); - return; - } - if (!/^https?:\/\//.test(url)) { - toast.error("Server address must start with http:// or https://"); - return; - } - - setIsLoading(true); - try { - await saveServerConfig({ - serverUrl: url, - lastUpdated: new Date().toISOString(), - }); - setSelectedServer({ name: "Server", ip: url }); - setShowServerManager(false); - setShowLoginForm(true); - } catch (error: any) { - toast.error(`Failed to save server: ${error?.message || "Unknown error"}`); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - {/* Brand mark */} - - - - - TERMIX - - - CONNECT TO YOUR SERVER - - - {/* Card */} - - - - } - onSubmitEditing={handleConnect} - /> - - - Enter the address of your self-hosted Termix server, including - http:// or https://. - - - - - - {/* HTTPS / cert hint */} - - - - Using a self-signed certificate? Install its root CA on your - device first. Local HTTP servers are supported. - - - - - - ); -} diff --git a/app/components/ConnectEmptyState.tsx b/app/components/ConnectEmptyState.tsx new file mode 100644 index 0000000..5092198 --- /dev/null +++ b/app/components/ConnectEmptyState.tsx @@ -0,0 +1,43 @@ +import { View } from "react-native"; +import { ServerOff } from "lucide-react-native"; +import { Text, Button } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { useAppContext } from "@/app/AppContext"; + +/** + * Shown inside a tab when there is no authenticated server connection. Prompts + * the user to connect, opening the auth flow. Used across Hosts / Sessions so + * the disconnected state is consistent. + */ +export function ConnectEmptyState({ + title = "No server connected", + message = "Connect to a Termix server to manage your hosts, terminals and files.", +}: { + title?: string; + message?: string; +}) { + const color = useThemeColor(); + const { hasServerConfigured, openAuthFlow } = useAppContext(); + + return ( + + + + + + {title} + + + {message} + + + + ); +} diff --git a/app/main-axios.ts b/app/main-axios.ts index 518e6a6..b7fe15a 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -1850,6 +1850,54 @@ function extractJwtFromSetCookie(headers: any): string | null { return null; } +/** + * Detects whether the server URL sits behind a reverse-proxy authentication + * gate (Cloudflare Access, Authelia, etc.) that intercepts requests and serves + * its own HTML login page instead of forwarding them to Termix. In that case a + * native login form cannot work — the user must authenticate to the proxy in a + * browser context (the WebView/SSO flow). + * + * Returns true when a known JSON endpoint responds with HTML (or otherwise + * non-JSON) content, which is the tell-tale sign of an interposing auth proxy. + */ +export async function isReverseProxyAuthGate(): Promise { + const probe = async (base: string): Promise => { + try { + const url = `${base.replace(/\/$/, "")}/users/registration-allowed`; + const res = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": `Termix-Mobile/${Platform.OS === "android" ? "Android" : "iOS"}`, + }, + }); + const contentType = (res.headers.get("content-type") || "").toLowerCase(); + const body = await res.text(); + // A genuine Termix API endpoint returns JSON. An auth proxy returns its + // login HTML (often with a 200 or a redirect-resolved 200). + if (contentType.includes("application/json")) return false; + const looksHtml = + contentType.includes("text/html") || + /^\s*<(?:!doctype|html)/i.test(body); + if (looksHtml) return true; + // Unknown content-type but valid JSON body → treat as real API. + try { + JSON.parse(body); + return false; + } catch { + return looksHtml ? true : null; + } + } catch { + return null; + } + }; + + const rootResult = await probe(getRootBase(8081)); + if (rootResult !== null) return rootResult; + const sshResult = await probe(getSshBase(8081)); + return sshResult === true; +} + async function loginWithFetch( baseUrl: string, username: string, @@ -1862,14 +1910,38 @@ async function loginWithFetch( body: JSON.stringify({ username, password }), }); + const contentType = ( + fetchResponse.headers.get("content-type") || "" + ).toLowerCase(); + const rawBody = await fetchResponse.text(); + + // A reverse-proxy auth gate intercepts the request and returns its HTML login + // page instead of Termix JSON. Surface a clear, actionable error rather than + // crashing on JSON.parse of "…". + const looksHtml = + contentType.includes("text/html") || /^\s*<(?:!doctype|html)/i.test(rawBody); + if (looksHtml) { + const err: any = new Error( + "This server is behind a login proxy. Use the external sign-in option instead.", + ); + err.code = "PROXY_AUTH_GATE"; + err.response = { status: fetchResponse.status, data: {} }; + throw err; + } + if (!fetchResponse.ok) { - const errData = await fetchResponse.json().catch(() => ({})); + let errData: any = {}; + try { + errData = rawBody ? JSON.parse(rawBody) : {}; + } catch { + errData = {}; + } const err: any = new Error(errData?.error || "Login failed"); err.response = { status: fetchResponse.status, data: errData }; throw err; } - const data = await fetchResponse.json(); + const data = JSON.parse(rawBody); let token: string | null = data.token || null; const setCookie = fetchResponse.headers.get("set-cookie"); @@ -1910,6 +1982,9 @@ export async function loginUser( return { ...data, token: finalToken || "" }; } catch (error: any) { + if (error?.code === "PROXY_AUTH_GATE") { + throw new ApiError(error.message, 0, "PROXY_AUTH_GATE"); + } if (error?.response?.status === 404) { try { const altBase = getSshBase(8081); @@ -1924,7 +1999,10 @@ export async function loginUser( } return { ...data, token: token || "" }; - } catch (e) { + } catch (e: any) { + if (e?.code === "PROXY_AUTH_GATE") { + throw new ApiError(e.message, 0, "PROXY_AUTH_GATE"); + } handleApiError(e, "login user"); } } diff --git a/app/tabs/settings/Settings.tsx b/app/tabs/settings/Settings.tsx index a526a40..c3e6954 100644 --- a/app/tabs/settings/Settings.tsx +++ b/app/tabs/settings/Settings.tsx @@ -11,10 +11,11 @@ import { LogOut, Lock, KeyRound, + Server as ServerIcon, } from "lucide-react-native"; import { useAppContext } from "@/app/AppContext"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; -import { useTheme } from "@/app/contexts/ThemeContext"; +import { useTheme, useThemeColor } from "@/app/contexts/ThemeContext"; import { useAppLock } from "@/app/contexts/AppLockContext"; import { clearAuth, @@ -22,6 +23,7 @@ import { getUserInfo, getVersionInfo, changePassword, + getCurrentServerUrl, } from "@/app/main-axios"; import { Screen } from "@/app/components/Screen"; import { @@ -35,7 +37,6 @@ import { SegmentedControl, Dialog, } from "@/app/components/ui"; -import { useThemeColor } from "@/app/contexts/ThemeContext"; import { ACCENT_PRESET_COLORS, FONT_SIZES, @@ -49,11 +50,7 @@ import { toast } from "@/app/utils/toast"; export default function Settings() { const router = useRouter(); const color = useThemeColor(); - const { - setAuthenticated, - setShowLoginForm, - setShowServerManager, - } = useAppContext(); + const { isAuthenticated, setAuthenticated, openAuthFlow } = useAppContext(); const { clearAllSessions } = useTerminalSessions(); const { theme, setTheme, accent, setAccent, fontSize, setFontSize } = useTheme(); @@ -63,6 +60,9 @@ export default function Settings() { const [isAdmin, setIsAdmin] = useState(false); const [totpEnabled, setTotpEnabled] = useState(false); const [version, setVersion] = useState(""); + const [serverUrl, setServerUrl] = useState( + () => getCurrentServerUrl() ?? "", + ); const [open, setOpen] = useState("appearance"); // App-lock PIN dialog @@ -74,6 +74,13 @@ export default function Settings() { const [newPw, setNewPw] = useState(""); useEffect(() => { + setServerUrl(getCurrentServerUrl() ?? ""); + if (!isAuthenticated) { + setUsername("—"); + setIsAdmin(false); + setTotpEnabled(false); + return; + } getUserInfo() .then((u) => { setUsername(u.username ?? "—"); @@ -84,23 +91,25 @@ export default function Settings() { getVersionInfo() .then((v) => setVersion(v?.localVersion ?? v?.version ?? "")) .catch(() => {}); - }, []); + }, [isAuthenticated]); const toggle = (id: string) => setOpen((o) => (o === id ? null : id)); const handleLogout = async () => { try { await logoutUser(); - await clearAuth(); - clearAllSessions(); - setAuthenticated(false); - setShowLoginForm(true); - setShowServerManager(false); } catch { - // best-effort + // best-effort — server-side logout may fail if token already expired } + await clearAuth(); + clearAllSessions(); + setAuthenticated(false); + // The tabs fall back to their "no server connected" empty states; the user + // re-authenticates from there or from this Server section. }; + const handleChangeServer = () => openAuthFlow("server"); + const handleAppLockToggle = async (v: boolean) => { if (v) { setPin(""); @@ -142,7 +151,66 @@ export default function Settings() { + {/* Server */} + } + open={open === "server"} + onToggle={() => toggle("server")} + > + + + + + + + {serverUrl + ? serverUrl.replace(/^https?:\/\//, "") + : "Not configured"} + + + + {isAuthenticated + ? "Connected" + : serverUrl + ? "Configured but not signed in" + : "No server added yet"} + + + + + + {isAuthenticated ? ( + + ) : serverUrl ? ( + + ) : null} + + + {/* Account */} + {isAuthenticated ? ( } @@ -181,16 +249,9 @@ export default function Settings() { ) : null} - + ) : null} {/* Appearance — headline feature */} - - - + + + ) : null} From fb8007a17e33ea854e87f93a2a21fd275528d04c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 29 May 2026 16:33:27 -0500 Subject: [PATCH 09/35] chore: update app icon/background color --- app.json | 8 ++++---- assets/images/adaptive-icon.png | Bin 19841 -> 28287 bytes assets/images/icon.png | Bin 52784 -> 52435 bytes assets/images/ios-dark.png | Bin 46704 -> 51133 bytes assets/images/ios-tinted.png | Bin 46481 -> 46753 bytes assets/images/splash-icon.png | Bin 27523 -> 44401 bytes 6 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app.json b/app.json index 71a2992..454f837 100644 --- a/app.json +++ b/app.json @@ -7,7 +7,7 @@ "icon": "./assets/images/icon.png", "scheme": "termix-mobile", "githubUrl": "https://github.com/Termix-SSH/Mobile", - "backgroundColor": "#18181b", + "backgroundColor": "#0c0d0b", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { @@ -29,7 +29,7 @@ "package": "com.karmaa.termix", "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#18181b" + "backgroundColor": "#0c0d0b" } }, "web": { @@ -47,12 +47,12 @@ [ "expo-splash-screen", { - "backgroundColor": "#18181b", + "backgroundColor": "#0c0d0b", "image": "./assets/images/splash-icon.png", "imageWidth": 200, "dark": { "image": "./assets/images/splash-icon.png", - "backgroundColor": "#18181b" + "backgroundColor": "#0c0d0b" } } ], diff --git a/assets/images/adaptive-icon.png b/assets/images/adaptive-icon.png index 10e30be55deba24325d5e3f97fb7f76d40cf6f62..22b7545b4f2933b677dbf8966fdf86f4965baeee 100644 GIT binary patch literal 28287 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 literal 19841 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

~pO*^e1Bw}u)(L<+2YZSt3T0)6e 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 literal 52784 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

lqp(~41uxtV3 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

@u0in;3&$ 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 diff --git a/assets/images/ios-dark.png b/assets/images/ios-dark.png index 8014a1a2090d33e0e21721fc10f52ed9f12b5251..53a83c0b571d72032a929a204bf1d1722b901a7b 100644 GIT binary patch literal 51133 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

8}IR2SJd&s9Cx%VNRk> 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 b`%dI+m6f<1+DZxn{v1DScqsdTP2m3m`9nDD literal 46704 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 O&o--<+*;)P+y4XSHhOXZ literal 46481 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 RV<^Nd0 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%0Ahb6Ueh-pGy0B1Ap3?Y{N*nDD;8vCrB596ALO@d& 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

(1?OWdcsN&9-r%FXYSlE7A=bSHMLHSWv%YTFbI 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 literal 27523 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 Date: Fri, 29 May 2026 16:39:43 -0500 Subject: [PATCH 10/35] fix: remove unused fields in the settings tab --- app/constants/theme.ts | 11 ---- app/contexts/ThemeContext.tsx | 23 +------- app/main-axios.ts | 15 ------ app/tabs/settings/Settings.tsx | 98 +--------------------------------- 4 files changed, 3 insertions(+), 144 deletions(-) diff --git a/app/constants/theme.ts b/app/constants/theme.ts index 7414f4c..f2c438b 100644 --- a/app/constants/theme.ts +++ b/app/constants/theme.ts @@ -19,8 +19,6 @@ export type ThemeId = | "one-dark" | "gruvbox"; -export type FontSizeId = "xs" | "sm" | "md" | "lg" | "xl"; - /** Full variable set keyed by token name (without the leading --). */ export type ThemeVars = Record; @@ -419,14 +417,6 @@ export const ACCENT_PRESET_COLORS = [ export const DEFAULT_ACCENT = "#f59145"; -export const FONT_SIZES: { id: FontSizeId; label: string; px: number }[] = [ - { id: "xs", label: "XS", px: 11 }, - { id: "sm", label: "Small", px: 12 }, - { id: "md", label: "Normal", px: 14 }, - { id: "lg", label: "Large", px: 16 }, - { id: "xl", label: "XL", px: 18 }, -]; - export const FOLDER_COLORS = [ "#ef4444", "#f97316", @@ -457,5 +447,4 @@ export function hexToRgbTriplet(hex: string): string | null { export const STORAGE_KEYS = { theme: "termix-theme", accent: "termix-accent", - fontSize: "termix-font-size", } as const; diff --git a/app/contexts/ThemeContext.tsx b/app/contexts/ThemeContext.tsx index 45da87e..2450841 100644 --- a/app/contexts/ThemeContext.tsx +++ b/app/contexts/ThemeContext.tsx @@ -11,12 +11,10 @@ import { vars } from "nativewind"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { DEFAULT_ACCENT, - FONT_SIZES, hexToRgbTriplet, STORAGE_KEYS, THEME_IS_DARK, THEME_VARS, - type FontSizeId, type ThemeId, } from "@/app/constants/theme"; @@ -28,12 +26,9 @@ interface ThemeContextValue { isDark: boolean; accent: string; // hex, e.g. "#f59145" accentTriplet: string; // "245 145 69" - fontSize: FontSizeId; - fontScale: number; // multiplier vs the 14px baseline ready: boolean; setTheme: (t: ThemeId) => void; setAccent: (hex: string) => void; - setFontSize: (id: FontSizeId) => void; } const ThemeContext = createContext(null); @@ -44,22 +39,19 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { const systemScheme = useRNColorScheme(); const [theme, setThemeState] = useState("dark"); const [accent, setAccentState] = useState(DEFAULT_ACCENT); - const [fontSize, setFontSizeState] = useState("md"); const [ready, setReady] = useState(false); // Load persisted preferences once on mount. useEffect(() => { (async () => { try { - const [savedTheme, savedAccent, savedFont] = await Promise.all([ + const [savedTheme, savedAccent] = await Promise.all([ AsyncStorage.getItem(STORAGE_KEYS.theme), AsyncStorage.getItem(STORAGE_KEYS.accent), - AsyncStorage.getItem(STORAGE_KEYS.fontSize), ]); if (savedTheme) setThemeState(savedTheme as ThemeId); if (savedAccent && hexToRgbTriplet(savedAccent)) setAccentState(savedAccent); - if (savedFont) setFontSizeState(savedFont as FontSizeId); } catch { // best-effort; fall back to defaults } finally { @@ -79,18 +71,11 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { AsyncStorage.setItem(STORAGE_KEYS.accent, hex).catch(() => {}); }, []); - const setFontSize = useCallback((id: FontSizeId) => { - setFontSizeState(id); - AsyncStorage.setItem(STORAGE_KEYS.fontSize, id).catch(() => {}); - }, []); - const resolvedTheme: Exclude = theme === "system" ? (systemScheme === "light" ? "light" : "dark") : theme; const isDark = THEME_IS_DARK[resolvedTheme]; const accentTriplet = hexToRgbTriplet(accent) ?? DEFAULT_ACCENT_TRIPLET; - const fontScale = - (FONT_SIZES.find((f) => f.id === fontSize)?.px ?? 14) / 14; // Build the CSS-variable style for the active theme + accent. const themeStyle = useMemo(() => { @@ -109,12 +94,9 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { isDark, accent, accentTriplet, - fontSize, - fontScale, ready, setTheme, setAccent, - setFontSize, }), [ theme, @@ -122,12 +104,9 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { isDark, accent, accentTriplet, - fontSize, - fontScale, ready, setTheme, setAccent, - setFontSize, ], ); diff --git a/app/main-axios.ts b/app/main-axios.ts index b7fe15a..b3e83b1 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -2165,21 +2165,6 @@ export async function completePasswordReset( } } -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"); diff --git a/app/tabs/settings/Settings.tsx b/app/tabs/settings/Settings.tsx index c3e6954..6789b44 100644 --- a/app/tabs/settings/Settings.tsx +++ b/app/tabs/settings/Settings.tsx @@ -7,10 +7,8 @@ import { Shield, SlidersHorizontal, ChevronRight, - Type, LogOut, Lock, - KeyRound, Server as ServerIcon, } from "lucide-react-native"; import { useAppContext } from "@/app/AppContext"; @@ -22,7 +20,6 @@ import { logoutUser, getUserInfo, getVersionInfo, - changePassword, getCurrentServerUrl, } from "@/app/main-axios"; import { Screen } from "@/app/components/Screen"; @@ -34,16 +31,13 @@ import { AccordionSection, SettingRow, FakeSwitch, - SegmentedControl, Dialog, } from "@/app/components/ui"; import { ACCENT_PRESET_COLORS, - FONT_SIZES, THEMES, THEME_LABELS, type ThemeId, - type FontSizeId, } from "@/app/constants/theme"; import { toast } from "@/app/utils/toast"; @@ -52,8 +46,7 @@ export default function Settings() { const color = useThemeColor(); const { isAuthenticated, setAuthenticated, openAuthFlow } = useAppContext(); const { clearAllSessions } = useTerminalSessions(); - const { theme, setTheme, accent, setAccent, fontSize, setFontSize } = - useTheme(); + const { theme, setTheme, accent, setAccent } = useTheme(); const appLock = useAppLock(); const [username, setUsername] = useState("—"); @@ -68,10 +61,6 @@ export default function Settings() { // App-lock PIN dialog const [pinDialog, setPinDialog] = useState(false); const [pin, setPin] = useState(""); - // Change-password dialog - const [pwDialog, setPwDialog] = useState(false); - const [curPw, setCurPw] = useState(""); - const [newPw, setNewPw] = useState(""); useEffect(() => { setServerUrl(getCurrentServerUrl() ?? ""); @@ -130,22 +119,6 @@ export default function Settings() { toast.success("App lock enabled"); }; - const handleChangePassword = async () => { - if (!curPw || newPw.length < 6) { - toast.error("Enter current password and a new one (min 6 chars)"); - return; - } - try { - await changePassword(curPw, newPw); - toast.success("Password updated"); - setPwDialog(false); - setCurPw(""); - setNewPw(""); - } catch (e: any) { - toast.error(e?.response?.data?.error || "Failed to update password"); - } - }; - return ( hex - - {/* Font size */} - - - - - - - value={fontSize} - onChange={setFontSize} - options={FONT_SIZES.map((f) => ({ id: f.id, label: f.label }))} - /> - @@ -356,28 +316,13 @@ export default function Settings() { ? "Require biometrics or PIN to open the app" : "Require a PIN to open the app" } + last > - {isAuthenticated ? ( - - - - ) : null} @@ -452,45 +397,6 @@ export default function Settings() { autoFocus /> - - {/* Change-password dialog */} -

setPwDialog(false)} - title="Change Password" - icon={} - footer={ - <> - - - - } - > - - - - - - - - - - - ); } From 59b956f1f6e388ec9cc8c03d36c33600bc60b05c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 29 May 2026 16:52:18 -0500 Subject: [PATCH 11/35] feat: improve the user pin system and update all settings modals to use new ui --- app/components/LockScreen.tsx | 92 ++- app/contexts/AppLockContext.tsx | 62 +- app/tabs/settings/KeyboardCustomization.tsx | 650 ++++++++---------- app/tabs/settings/Settings.tsx | 105 ++- app/tabs/settings/TerminalCustomization.tsx | 440 +++++------- .../settings/components/DraggableKeyList.tsx | 70 +- .../settings/components/DraggableRowList.tsx | 80 +-- app/tabs/settings/components/KeySelector.tsx | 176 ++--- .../components/UnifiedDraggableList.tsx | 108 +-- 9 files changed, 849 insertions(+), 934 deletions(-) diff --git a/app/components/LockScreen.tsx b/app/components/LockScreen.tsx index 07a6847..756d52f 100644 --- a/app/components/LockScreen.tsx +++ b/app/components/LockScreen.tsx @@ -1,55 +1,93 @@ import { useEffect, useState } from "react"; -import { View } from "react-native"; -import { Lock, Fingerprint, Delete } from "lucide-react-native"; -import { Pressable } from "react-native"; +import { View, Pressable } from "react-native"; +import { Lock, Fingerprint, Delete, ChevronLeft } from "lucide-react-native"; import { useAppLock } from "@/app/contexts/AppLockContext"; import { Text } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; +interface LockScreenProps { + title?: string; + subtitle?: string; + /** + * Verify-mode overrides. When provided, the keypad calls these instead of the + * context's unlock methods and does NOT change global lock state — used for + * re-authentication (e.g. disabling app lock in Settings). + */ + onVerifyPin?: (pin: string) => Promise; + onBiometric?: () => Promise; + /** Called after a successful PIN/biometric verification. */ + onSuccess?: () => void; + /** When set, shows a Cancel affordance and makes the screen dismissible. */ + onCancel?: () => void; +} + /** - * Full-screen lock overlay shown when app lock is enabled and the app is - * locked. Offers biometrics (if available) and a numeric PIN keypad. + * Full-screen 4-digit PIN gate. Used both as the app-lock overlay (default + * mode) and as a re-auth screen in Settings (verify mode via props). Biometrics + * are opt-in: the user taps the fingerprint button — they are never prompted + * automatically, and the OS device passcode is never offered as a fallback. */ -export function LockScreen() { +export function LockScreen({ + title = "Termix Locked", + subtitle = "Enter your PIN to continue", + onVerifyPin, + onBiometric, + onSuccess, + onCancel, +}: LockScreenProps = {}) { const { hasBiometrics, unlockWithBiometrics, unlockWithPin } = useAppLock(); const color = useThemeColor(); const [pin, setPin] = useState(""); const [error, setError] = useState(false); - useEffect(() => { - // Offer biometrics immediately on appear. - if (hasBiometrics) unlockWithBiometrics(); - }, [hasBiometrics, unlockWithBiometrics]); + const verifyPin = onVerifyPin ?? unlockWithPin; + const biometric = onBiometric ?? unlockWithBiometrics; useEffect(() => { - if (pin.length === 4) { - unlockWithPin(pin).then((ok) => { - if (!ok) { - setError(true); - setTimeout(() => { - setPin(""); - setError(false); - }, 600); - } - }); - } - }, [pin, unlockWithPin]); + if (pin.length !== 4) return; + verifyPin(pin).then((ok) => { + if (ok) { + onSuccess?.(); + } else { + setError(true); + setTimeout(() => { + setPin(""); + setError(false); + }, 600); + } + }); + }, [pin, verifyPin, onSuccess]); const press = (d: string) => { if (pin.length < 4) setPin((p) => p + d); }; + const handleBiometric = async () => { + if (!hasBiometrics) return; + const ok = await biometric(); + if (ok) onSuccess?.(); + }; + return ( + {onCancel ? ( + + + Cancel + + ) : null} + - Termix Locked - - - Enter your PIN to continue + {title} + {subtitle} {/* PIN dots */} @@ -82,7 +120,7 @@ export function LockScreen() { ))} hasBiometrics && unlockWithBiometrics()} + onPress={handleBiometric} className="w-16 h-16 items-center justify-center" > {hasBiometrics ? ( diff --git a/app/contexts/AppLockContext.tsx b/app/contexts/AppLockContext.tsx index 97816f0..3aab67a 100644 --- a/app/contexts/AppLockContext.tsx +++ b/app/contexts/AppLockContext.tsx @@ -28,10 +28,21 @@ interface AppLockContextValue { hasBiometrics: boolean; /** Enable app lock with a numeric PIN (biometrics used when available). */ enable: (pin: string) => Promise; + /** + * Tear down app lock. Performs no verification — callers MUST authenticate + * the user (via `verifyPin` / `authenticateBiometrics`) first. + */ disable: () => Promise; - /** Attempt unlock via biometrics; returns success. */ + /** Compare a PIN against the stored one. Does NOT change lock state. */ + verifyPin: (pin: string) => Promise; + /** + * Prompt for biometrics (no device-passcode fallback). Does NOT change lock + * state — used for re-authentication in Settings. + */ + authenticateBiometrics: () => Promise; + /** Attempt unlock via biometrics; unlocks on success. Returns success. */ unlockWithBiometrics: () => Promise; - /** Attempt unlock via PIN; returns success. */ + /** Attempt unlock via PIN; unlocks on success. Returns success. */ unlockWithPin: (pin: string) => Promise; } @@ -46,13 +57,17 @@ export function AppLockProvider({ children }: { children: React.ReactNode }) { useEffect(() => { (async () => { - const [isEnabled, hw] = await Promise.all([ + const [isEnabled, hw, enrolled] = await Promise.all([ AsyncStorage.getItem(ENABLED_KEY), LocalAuthentication.hasHardwareAsync().catch(() => false), + LocalAuthentication.isEnrolledAsync().catch(() => false), ]); const on = isEnabled === "true"; setEnabled(on); - setHasBiometrics(!!hw); + // Only treat biometrics as available when the device has a sensor AND a + // biometric is actually enrolled — otherwise the fingerprint button would + // appear but do nothing. + setHasBiometrics(!!hw && !!enrolled); if (on) setLocked(true); // lock on cold start })(); }, []); @@ -87,33 +102,44 @@ export function AppLockProvider({ children }: { children: React.ReactNode }) { setLocked(false); }, []); - const unlockWithBiometrics = useCallback(async () => { + // Prompt biometrics without changing lock state. Device-passcode fallback is + // disabled so iOS never shows the phone passcode prompt — the in-app PIN is + // the only fallback. + const authenticateBiometrics = useCallback(async () => { try { const enrolled = await LocalAuthentication.isEnrolledAsync(); if (!enrolled) return false; const res = await LocalAuthentication.authenticateAsync({ promptMessage: "Unlock Termix", - fallbackLabel: "Use PIN", + disableDeviceFallback: true, + cancelLabel: "Use PIN", }); - if (res.success) { - setLocked(false); - return true; - } - return false; + return res.success; } catch { return false; } }, []); - const unlockWithPin = useCallback(async (pin: string) => { + const unlockWithBiometrics = useCallback(async () => { + const ok = await authenticateBiometrics(); + if (ok) setLocked(false); + return ok; + }, [authenticateBiometrics]); + + const verifyPin = useCallback(async (pin: string) => { const stored = await SecureStore.getItemAsync(PIN_KEY).catch(() => null); - if (stored && stored === pin) { - setLocked(false); - return true; - } - return false; + return !!stored && stored === pin; }, []); + const unlockWithPin = useCallback( + async (pin: string) => { + const ok = await verifyPin(pin); + if (ok) setLocked(false); + return ok; + }, + [verifyPin], + ); + return ( React.ReactNode }[] = [ + { id: "presets", label: "Presets", icon: (c) => }, + { id: "topbar", label: "Top Bar", icon: (c) => }, + { id: "fullKeyboard", label: "Full Keyboard", icon: (c) => }, + { id: "settings", label: "Settings", icon: (c) => }, +]; + export default function KeyboardCustomization() { const router = useRouter(); const insets = useSafeAreaInsets(); + const color = useThemeColor(); const { config, setPreset, @@ -49,9 +58,7 @@ export default function KeyboardCustomization() { const [activeTab, setActiveTab] = useState("presets"); const [showResetConfirm, setShowResetConfirm] = useState(false); - const [resetType, setResetType] = useState<"all" | "topbar" | "fullkeyboard">( - "all", - ); + const [resetType, setResetType] = useState<"all" | "topbar" | "fullkeyboard">("all"); const [showKeySelector, setShowKeySelector] = useState(false); const [addKeyMode, setAddKeyMode] = useState(null); const [selectedRowId, setSelectedRowId] = useState(null); @@ -82,7 +89,7 @@ export default function KeyboardCustomization() { }); }); - items.push({ type: "spacer", id: "spacer-1", height: 20 }); + items.push({ type: "spacer", id: "spacer-1", height: 16 }); items.push({ type: "header", @@ -104,7 +111,7 @@ export default function KeyboardCustomization() { }); }); - items.push({ type: "spacer", id: "spacer-2", height: 20 }); + items.push({ type: "spacer", id: "spacer-2", height: 16 }); items.push({ type: "button", @@ -169,15 +176,11 @@ export default function KeyboardCustomization() { }); }); - items.push({ - type: "spacer", - id: `row-close-${row.id}`, - height: 12, - }); + items.push({ type: "spacer", id: `row-close-${row.id}`, height: 12 }); } }); - items.push({ type: "spacer", id: "spacer-3", height: 20 }); + items.push({ type: "spacer", id: "spacer-3", height: 16 }); items.push({ type: "button", @@ -196,11 +199,11 @@ export default function KeyboardCustomization() { const handlePresetSelect = async (presetId: PresetType) => { try { await setPreset(presetId); - showToast.success( + toast.success( `Switched to ${PRESET_DEFINITIONS.find((p) => p.id === presetId)?.name} preset`, ); - } catch (error) { - showToast.error("Failed to switch preset"); + } catch { + toast.error("Failed to switch preset"); } }; @@ -208,16 +211,16 @@ export default function KeyboardCustomization() { try { if (addKeyMode === "pinned") { await addPinnedKey(key); - showToast.success(`Added ${key.label} to pinned keys`); + toast.success(`Added ${key.label} to pinned keys`); } else if (addKeyMode === "topbar") { await addTopBarKey(key); - showToast.success(`Added ${key.label} to top bar`); + toast.success(`Added ${key.label} to top bar`); } else if (addKeyMode === "row" && selectedRowId) { await addKeyToRow(selectedRowId, key); - showToast.success(`Added ${key.label} to row`); + toast.success(`Added ${key.label} to row`); } - } catch (error) { - showToast.error("Failed to add key"); + } catch { + toast.error("Failed to add key"); } }; @@ -228,98 +231,15 @@ export default function KeyboardCustomization() { }; const getExcludedKeys = (): string[] => { - if (addKeyMode === "pinned") { - return config.topBar.pinnedKeys.map((k) => k.id); - } else if (addKeyMode === "topbar") { - return config.topBar.keys.map((k) => k.id); - } else if (addKeyMode === "row" && selectedRowId) { + if (addKeyMode === "pinned") return config.topBar.pinnedKeys.map((k) => k.id); + if (addKeyMode === "topbar") return config.topBar.keys.map((k) => k.id); + if (addKeyMode === "row" && selectedRowId) { const row = config.fullKeyboard.rows.find((r) => r.id === selectedRowId); return row ? row.keys.map((k) => k.id) : []; } return []; }; - const handleKeySizeChange = async (size: "small" | "medium" | "large") => { - await updateSettings({ keySize: size }); - }; - - const handleCompactModeToggle = async (value: boolean) => { - await updateSettings({ compactMode: value }); - }; - - const handleHapticToggle = async (value: boolean) => { - await updateSettings({ hapticFeedback: value }); - }; - - const handleHintsToggle = async (value: boolean) => { - await updateSettings({ showHints: value }); - }; - - const handleReset = async () => { - try { - if (resetType === "all") { - await resetToDefault(); - showToast.success("Keyboard reset to default"); - } else if (resetType === "topbar") { - await resetTopBar(); - showToast.success("Top bar reset to default"); - } else if (resetType === "fullkeyboard") { - await resetFullKeyboard(); - showToast.success("Full keyboard reset to default"); - } - setShowResetConfirm(false); - } catch (error) { - showToast.error("Failed to reset"); - } - }; - - const renderPresets = () => ( - - - Keyboard Presets - - - Choose a preset layout optimized for different use cases - - - {PRESET_DEFINITIONS.map((preset) => ( - handlePresetSelect(preset.id)} - className={`mb-3 p-4 rounded-lg border ${ - config.preset === preset.id - ? "bg-green-900/20 border-green-500" - : "bg-[#1a1a1a] border-[#303032]" - }`} - > - - - {preset.name} - - {config.preset === preset.id && ( - - ACTIVE - - )} - - {preset.description} - - ))} - - {config.preset === "custom" && ( - - - Custom Layout - - - You have made custom changes. Select a preset above to reset to a - predefined layout. - - - )} - - ); - const validateTopBarDrag = (newData: UnifiedListItem[]): boolean => { const pinnedHeaderIndex = newData.findIndex( (item) => item.type === "header" && item.id === "header-pinned", @@ -332,144 +252,161 @@ export default function KeyboardCustomization() { ); for (let i = 0; i <= pinnedHeaderIndex; i++) { - const item = newData[i]; - if (item.type === "draggable-key") { - return false; - } + if (newData[i].type === "draggable-key") return false; } - for (let i = 0; i < newData.length; i++) { const item = newData[i]; if (item.type === "draggable-key" && item.section === "pinned") { - if (i <= pinnedHeaderIndex || i >= topbarHeaderIndex) { - return false; - } + if (i <= pinnedHeaderIndex || i >= topbarHeaderIndex) return false; } } - for (let i = 0; i < newData.length; i++) { const item = newData[i]; if (item.type === "draggable-key" && item.section === "topbar") { - if (i <= topbarHeaderIndex || i >= resetButtonIndex) { - return false; - } + if (i <= topbarHeaderIndex || i >= resetButtonIndex) return false; } } - return true; }; - const renderTopBar = () => ( - - { - if (!validateTopBarDrag(newData)) { - showToast.error("Cannot move items between sections"); - setListResetKey((prev) => prev + 1); - return; - } - - const pinnedKeys = newData - .filter( - (item) => - item.type === "draggable-key" && item.section === "pinned", - ) - .map((item) => (item as any).data); - - const topBarKeys = newData - .filter( - (item) => - item.type === "draggable-key" && item.section === "topbar", - ) - .map((item) => (item as any).data); - - reorderPinnedKeys(pinnedKeys); - reorderTopBarKeys(topBarKeys); - }} - onRemoveKey={(itemId, section) => { - const keyId = itemId.replace(`${section}-`, ""); - if (section === "pinned") { - removePinnedKey(keyId); - } else if (section === "topbar") { - removeTopBarKey(keyId); - } - }} - /> - - ); - const validateFullKeyboardDrag = (newData: UnifiedListItem[]): boolean => { const mainHeaderIndex = newData.findIndex( (item) => item.type === "header" && item.id === "header-rows", ); - const resetButtonIndex = newData.findIndex( (item) => item.type === "button" && item.id === "reset-fullkeyboard", ); for (let i = 0; i <= mainHeaderIndex; i++) { const item = newData[i]; - if (item.type === "draggable-key" || item.type === "draggable-row") { - return false; - } + if (item.type === "draggable-key" || item.type === "draggable-row") return false; } - for (let i = resetButtonIndex; i < newData.length; i++) { const item = newData[i]; - if (item.type === "draggable-key" || item.type === "draggable-row") { - return false; - } + if (item.type === "draggable-key" || item.type === "draggable-row") return false; } - if (!expandedRowId) { - return true; - } + if (!expandedRowId) return true; const rowKeysHeaderIndex = newData.findIndex( (item) => - item.type === "row-keys-header" && - (item as any).rowId === expandedRowId, + item.type === "row-keys-header" && (item as any).rowId === expandedRowId, ); - const rowCloseIndex = newData.findIndex( (item) => item.type === "spacer" && item.id === `row-close-${expandedRowId}`, ); - if (rowKeysHeaderIndex === -1 || rowCloseIndex === -1) { - return true; - } + if (rowKeysHeaderIndex === -1 || rowCloseIndex === -1) return true; for (let i = 0; i < newData.length; i++) { const item = newData[i]; - if ( - item.type === "draggable-key" && - (item as any).rowId === expandedRowId - ) { - if (i <= rowKeysHeaderIndex || i >= rowCloseIndex) { - return false; - } + if (item.type === "draggable-key" && (item as any).rowId === expandedRowId) { + if (i <= rowKeysHeaderIndex || i >= rowCloseIndex) return false; } } - for (let i = rowKeysHeaderIndex + 1; i < rowCloseIndex; i++) { const item = newData[i]; - if ( - item.type === "draggable-key" && - (item as any).rowId !== expandedRowId - ) { - return false; - } - if (item.type === "draggable-row") { - return false; - } + if (item.type === "draggable-key" && (item as any).rowId !== expandedRowId) return false; + if (item.type === "draggable-row") return false; } return true; }; + const renderPresets = () => ( + + + + Choose a preset layout optimized for different use cases. + + + {PRESET_DEFINITIONS.map((preset) => { + const isActive = config.preset === preset.id; + return ( + handlePresetSelect(preset.id)} + className={`bg-card border px-3 py-3 active:opacity-80 ${isActive ? "border-accent-brand/50" : "border-border"}`} + > + + + {preset.name} + + {isActive ? ( + + + Active + + + ) : null} + + + {preset.description} + + + ); + })} + + {config.preset === "custom" ? ( + + + Custom Layout + + + You have made custom changes. Select a preset above to reset to a + predefined layout. + + + ) : null} + + ); + + const renderTopBar = () => ( + + { + if (!validateTopBarDrag(newData)) { + toast.error("Cannot move items between sections"); + setListResetKey((prev) => prev + 1); + return; + } + + const pinnedKeys = newData + .filter( + (item) => item.type === "draggable-key" && item.section === "pinned", + ) + .map((item) => (item as any).data); + + const topBarKeys = newData + .filter( + (item) => item.type === "draggable-key" && item.section === "topbar", + ) + .map((item) => (item as any).data); + + reorderPinnedKeys(pinnedKeys); + reorderTopBarKeys(topBarKeys); + }} + onRemoveKey={(itemId, section) => { + const keyId = itemId.replace(`${section}-`, ""); + if (section === "pinned") removePinnedKey(keyId); + else if (section === "topbar") removeTopBarKey(keyId); + }} + /> + + ); + const renderFullKeyboard = () => ( { if (!validateFullKeyboardDrag(newData)) { - showToast.error("Cannot move items between sections"); + toast.error("Cannot move items between sections"); setListResetKey((prev) => prev + 1); return; } @@ -504,9 +441,7 @@ export default function KeyboardCustomization() { if (section === "row") { const match = itemId.match(/^row-(.+)-key-(.+)$/); if (match) { - const rowId = match[1]; - const keyId = match[2]; - removeKeyFromRow(rowId, keyId); + removeKeyFromRow(match[1], match[2]); } } }} @@ -515,148 +450,159 @@ export default function KeyboardCustomization() { ); const renderSettings = () => ( - - - Keyboard Settings - - - Adjust keyboard appearance and behavior - - - - - Key Size - - - {(["small", "medium", "large"] as const).map((size) => ( - handleKeySizeChange(size)} - className={`flex-1 p-3 rounded-lg border ${ - config.settings.keySize === size - ? "bg-green-900/20 border-green-500" - : "bg-[#1a1a1a] border-[#303032]" - }`} - > - + {/* Key Size */} + + + + + + {(["small", "medium", "large"] as const).map((size) => { + const isActive = config.settings.keySize === size; + return ( + updateSettings({ keySize: size })} + className={`flex-1 py-2.5 border items-center active:opacity-80 ${isActive ? "border-accent-brand/50 bg-accent-brand/10" : "border-border"}`} > - {size.charAt(0).toUpperCase() + size.slice(1)} - - - ))} + + {size.charAt(0).toUpperCase() + size.slice(1)} + + + ); + })} - - - Compact Mode - - Tighter spacing for more keys on screen - + {/* Toggles */} + + + - - + + + + + Compact Mode + + + Tighter spacing for more keys on screen + + + updateSettings({ compactMode: v })} + /> + - - - - Haptic Feedback - - - Vibrate on key press - - - - + + + + Haptic Feedback + + + Vibrate on key press + + + updateSettings({ hapticFeedback: v })} + /> + - - - Show Hints - - Display the “Customize in Settings” hint - + + + + Show Hints + + + Display the "Customize in Settings" hint + + + updateSettings({ showHints: v })} + /> + - - { setResetType("all"); setShowResetConfirm(true); }} - className="bg-red-900/20 border border-red-700 rounded-lg p-3" > - - Reset Everything to Default - - + Reset Everything to Default +
); + const resetMessage = + resetType === "all" + ? "This will reset all keyboard customizations to default settings." + : resetType === "topbar" + ? "This will reset the top bar to default keys." + : "This will reset the full keyboard to default rows."; + + const handleReset = async () => { + try { + if (resetType === "all") { + await resetToDefault(); + toast.success("Keyboard reset to default"); + } else if (resetType === "topbar") { + await resetTopBar(); + toast.success("Top bar reset to default"); + } else { + await resetFullKeyboard(); + toast.success("Full keyboard reset to default"); + } + setShowResetConfirm(false); + } catch { + toast.error("Failed to reset"); + } + }; + return ( - - - - router.back()}> - - ← Back - - - - Keyboard Customization - - - + + {/* Header */} + + router.back()} hitSlop={8} className="shrink-0"> + + + + Keyboard + - - - {[ - { id: "presets", label: "Presets" }, - { id: "topbar", label: "Top Bar" }, - { id: "fullKeyboard", label: "Full Keyboard" }, - { id: "settings", label: "Settings" }, - ].map((tab) => ( - + {TABS.map((tab) => { + const isActive = activeTab === tab.id; + return ( + setActiveTab(tab.id as TabType)} - className={`px-4 py-3 mr-2 ${ - activeTab === tab.id ? "border-b-2 border-green-500" : "" - }`} + onPress={() => setActiveTab(tab.id)} + className={`flex-1 items-center py-2.5 gap-1 border-b-2 ${isActive ? "border-accent-brand" : "border-transparent"}`} > + {tab.icon(isActive ? color("accent-brand") : color("muted-foreground"))} {tab.label} - - ))} - + + ); + })} {activeTab === "presets" && renderPresets()} @@ -678,48 +624,26 @@ export default function KeyboardCustomization() { } /> - setShowResetConfirm(false)} - > - setShowResetConfirm(false)} - > - - - Confirm Reset - - - {resetType === "all" - ? "This will reset all keyboard customizations to default settings." - : resetType === "topbar" - ? "This will reset the top bar to default keys." - : "This will reset the full keyboard to default rows."} - - - setShowResetConfirm(false)} - className="flex-1 bg-[#27272a] border border-[#3f3f46] rounded-lg p-3" - > - - Cancel - - - - - Reset - - - - - - + onClose={() => setShowResetConfirm(false)} + title="Confirm Reset" + description={resetMessage} + footer={ + <> + + + + } + /> ); } diff --git a/app/tabs/settings/Settings.tsx b/app/tabs/settings/Settings.tsx index 6789b44..db6df87 100644 --- a/app/tabs/settings/Settings.tsx +++ b/app/tabs/settings/Settings.tsx @@ -23,6 +23,7 @@ import { getCurrentServerUrl, } from "@/app/main-axios"; import { Screen } from "@/app/components/Screen"; +import { LockScreen } from "@/app/components/LockScreen"; import { Text, Button, @@ -58,9 +59,13 @@ export default function Settings() { ); const [open, setOpen] = useState("appearance"); - // App-lock PIN dialog + // App-lock PIN setup dialog (two-step: enter then confirm) const [pinDialog, setPinDialog] = useState(false); - const [pin, setPin] = useState(""); + const [pinStep, setPinStep] = useState<"enter" | "confirm">("enter"); + const [pin, setPin] = useState(""); // first entry + const [confirm, setConfirm] = useState(""); // second entry + // Re-auth gate shown before disabling app lock. + const [reauth, setReauth] = useState(null); useEffect(() => { setServerUrl(getCurrentServerUrl() ?? ""); @@ -99,19 +104,33 @@ export default function Settings() { const handleChangeServer = () => openAuthFlow("server"); - const handleAppLockToggle = async (v: boolean) => { + const handleAppLockToggle = (v: boolean) => { if (v) { setPin(""); + setConfirm(""); + setPinStep("enter"); setPinDialog(true); } else { - await appLock.disable(); - toast.success("App lock disabled"); + // Require re-authentication before disabling — the switch stays on + // (it's controlled by appLock.enabled) until the gate succeeds. + setReauth("disable"); } }; - const confirmPin = async () => { - if (pin.length < 4) { - toast.error("PIN must be at least 4 digits"); + const advancePin = async () => { + if (pinStep === "enter") { + if (pin.length !== 4) { + toast.error("PIN must be 4 digits"); + return; + } + setPinStep("confirm"); + return; + } + if (confirm !== pin) { + toast.error("PINs don't match"); + setPin(""); + setConfirm(""); + setPinStep("enter"); return; } await appLock.enable(pin); @@ -119,6 +138,13 @@ export default function Settings() { toast.success("App lock enabled"); }; + const closePinDialog = () => { + setPinDialog(false); + setPin(""); + setConfirm(""); + setPinStep("enter"); + }; + return ( - {/* App-lock PIN dialog */} + {/* App-lock PIN setup dialog (two-step) */} setPinDialog(false)} - title="Set App Lock PIN" - description="Choose a 4+ digit PIN. Biometrics will be used when available." + onClose={closePinDialog} + title={pinStep === "enter" ? "Set App Lock PIN" : "Confirm PIN"} + description={ + pinStep === "enter" + ? "Choose a 4-digit PIN. Biometrics can be used to unlock when available." + : "Re-enter your PIN to confirm." + } icon={} footer={ <> - - } > - setPin(v.replace(/\D/g, "").slice(0, 8))} - keyboardType="number-pad" - secureTextEntry - placeholder="••••" - autoFocus - /> + {pinStep === "enter" ? ( + setPin(v.replace(/\D/g, "").slice(0, 4))} + keyboardType="number-pad" + secureTextEntry + placeholder="••••" + autoFocus + /> + ) : ( + setConfirm(v.replace(/\D/g, "").slice(0, 4))} + keyboardType="number-pad" + secureTextEntry + placeholder="••••" + autoFocus + /> + )} + + {/* Re-auth gate before disabling app lock */} + {reauth === "disable" ? ( + setReauth(null)} + onSuccess={async () => { + await appLock.disable(); + setReauth(null); + toast.success("App lock disabled"); + }} + /> + ) : null} ); } diff --git a/app/tabs/settings/TerminalCustomization.tsx b/app/tabs/settings/TerminalCustomization.tsx index 268e262..2ff90c4 100644 --- a/app/tabs/settings/TerminalCustomization.tsx +++ b/app/tabs/settings/TerminalCustomization.tsx @@ -1,20 +1,26 @@ import React, { useState } from "react"; import { View, - Text, ScrollView, - TouchableOpacity, - Modal, Pressable, - TextInput, KeyboardAvoidingView, Platform, } from "react-native"; import { useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ArrowLeft, Type, AlignLeft } from "lucide-react-native"; import { useTerminalCustomization } from "@/app/contexts/TerminalCustomizationContext"; -import { showToast } from "@/app/utils/toast"; +import { toast } from "@/app/utils/toast"; import { TERMINAL_FONTS } from "@/constants/terminal-themes"; +import { + Text, + Button, + Label, + Input, + Dialog, + FakeSwitch, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; const FONT_SIZE_OPTIONS = [ { label: "Extra Small", value: 12 }, @@ -28,6 +34,7 @@ const FONT_SIZE_OPTIONS = [ export default function TerminalCustomization() { const router = useRouter(); const insets = useSafeAreaInsets(); + const color = useThemeColor(); const { config, updateFontFamily, updateFontSize, resetToDefault } = useTerminalCustomization(); const [showResetConfirm, setShowResetConfirm] = useState(false); @@ -41,330 +48,265 @@ export default function TerminalCustomization() { const handleFontSizeChange = async (fontSize: number) => { try { await updateFontSize(fontSize); - showToast.success(`Font size updated to ${fontSize}px`); + toast.success(`Font size updated to ${fontSize}px`); } catch { - showToast.error("Failed to update font size"); + toast.error("Failed to update font size"); } }; const handleFontFamilyChange = async (fontFamily: string, label: string) => { try { await updateFontFamily(fontFamily); - showToast.success(`Font updated to ${label}`); + toast.success(`Font updated to ${label}`); } catch { - showToast.error("Failed to update font"); + toast.error("Failed to update font"); } }; const handleReset = async () => { try { await resetToDefault(); - showToast.success("Terminal settings reset to default"); + toast.success("Terminal settings reset to default"); setShowResetConfirm(false); } catch { - showToast.error("Failed to reset settings"); + toast.error("Failed to reset settings"); } }; const handleCustomFontSize = async () => { const fontSize = parseInt(customFontSize); if (isNaN(fontSize) || fontSize <= 0) { - showToast.error("Please enter a valid font size"); + toast.error("Please enter a valid font size"); return; } try { await updateFontSize(fontSize); - showToast.success(`Font size updated to ${fontSize}px`); + toast.success(`Font size updated to ${fontSize}px`); setShowCustomInput(false); setCustomFontSize(""); } catch { - showToast.error("Failed to update font size"); + toast.error("Failed to update font size"); } }; return ( - - - - router.back()}> - - ← Back - - - - Terminal Customization - - - + + {/* Header */} + + router.back()} hitSlop={8} className="shrink-0"> + + + + Terminal + - - Terminal Settings - - - Customize terminal appearance and behavior - - - - Font - - Select the terminal font family. Nerd Font support depends on the - selected font being available to the WebView. - - - {TERMINAL_FONTS.map((option) => { + {/* Font Family */} + + + + + Font Family + + + + + Nerd Font support depends on the selected font being available to + the WebView. + + {TERMINAL_FONTS.map((option, i) => { const isActive = config.fontFamily === option.value; + const isLast = i === TERMINAL_FONTS.length - 1; return ( - - handleFontFamilyChange(option.value, option.label) - } - className={`rounded-lg border p-4 ${ - isActive - ? "border-green-500 bg-green-900/20" - : "border-[#303032] bg-[#1a1a1a]" - }`} + onPress={() => handleFontFamilyChange(option.value, option.label)} + className={`flex-row items-center justify-between py-2.5 ${!isLast ? "border-b border-border" : ""}`} > - - - - {option.label} - + + + {option.label} + + + Aa Bb Cc 123 + + + {isActive ? ( + - Aa Bb Cc 123  + Active - {isActive && ( - - - ACTIVE - - - )} - - + ) : null} + ); })} - - - Font Size - - - Base font size for terminal text. The actual size will be adjusted - 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) => ( - handleFontSizeChange(option.value)} - className={`rounded-lg border p-4 ${ - config.fontSize === option.value - ? "border-green-500 bg-green-900/20" - : "border-[#303032] bg-[#1a1a1a]" - }`} - > - + {/* Font Size */} + + + + + Font Size + + + + + Base size for terminal text. Overrides the font size configured on + the host in Termix Web UI. + + {FONT_SIZE_OPTIONS.map((option, i) => { + const isActive = config.fontSize === option.value; + return ( + handleFontSizeChange(option.value)} + className={`flex-row items-center justify-between py-2.5 ${i < FONT_SIZE_OPTIONS.length - 1 || !isCustomFontSize ? "border-b border-border" : ""}`} + > {option.label} - - {option.value}px base size + + {option.value}px - {config.fontSize === option.value && ( - - - ACTIVE + {isActive ? ( + + + Active - )} - - - ))} + ) : null} + + ); + })} - setShowCustomInput(true)} - className={`rounded-lg border p-4 ${ - isCustomFontSize - ? "border-green-500 bg-green-900/20" - : "border-[#303032] bg-[#1a1a1a]" - }`} + className="flex-row items-center justify-between py-2.5" > - - + + + Custom + + + {isCustomFontSize + ? `${config.fontSize}px` + : "Enter any size"} + + + {isCustomFontSize ? ( + - Custom - - - {isCustomFontSize - ? `${config.fontSize}px base size` - : "Enter any custom size"} + Active - {isCustomFontSize && ( - - - ACTIVE - - - )} - - + ) : null} + - setShowResetConfirm(true)} - className="rounded-lg border border-red-700 bg-red-900/20 p-3" > - - Reset to Default - - + Reset to Default + - { + onClose={() => { setShowCustomInput(false); setCustomFontSize(""); }} - supportedOrientations={["portrait", "landscape"]} + title="Custom Font Size" + description="Enter your preferred font size for the terminal." + footer={ + <> + + + + } > - - { - setShowCustomInput(false); - setCustomFontSize(""); - }} - > - - - Custom Font Size - - - Enter your preferred font size for the terminal. - - - - { - setShowCustomInput(false); - setCustomFontSize(""); - }} - className="flex-1 rounded-lg border border-[#3f3f46] bg-[#27272a] p-3" - > - - Cancel - - - - - Apply - - - - - - - + + - setShowResetConfirm(false)} - supportedOrientations={["portrait", "landscape"]} - > - setShowResetConfirm(false)} - > - - - Confirm Reset - - - This will reset all terminal customizations to default settings. - - - setShowResetConfirm(false)} - className="flex-1 rounded-lg border border-[#3f3f46] bg-[#27272a] p-3" - > - - Cancel - - - - - Reset - - - - - - + onClose={() => setShowResetConfirm(false)} + title="Confirm Reset" + description="This will reset all terminal customizations to default settings." + footer={ + <> + + + + } + /> ); } diff --git a/app/tabs/settings/components/DraggableKeyList.tsx b/app/tabs/settings/components/DraggableKeyList.tsx index 4d3796b..d02d00b 100644 --- a/app/tabs/settings/components/DraggableKeyList.tsx +++ b/app/tabs/settings/components/DraggableKeyList.tsx @@ -1,7 +1,9 @@ import React from "react"; -import { View, Text, TouchableOpacity } from "react-native"; +import { View, Pressable } from "react-native"; import { KeyConfig } from "@/types/keyboard"; -import { GripVertical } from "lucide-react-native"; +import { GripVertical, X } from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface RenderKeyItemProps { item: KeyConfig; @@ -16,57 +18,49 @@ export function renderKeyItem({ drag, isActive, }: RenderKeyItemProps) { + return ; +} + +function KeyItem({ item, onRemove, drag, isActive }: RenderKeyItemProps) { + const color = useThemeColor(); + return ( - - + - - + + - + - - {item.label} + + + {item.label} + - {item.category} + {item.category} - {item.description && ( - + {item.description ? ( + {item.description} - )} + ) : null} - - - × - - + + ); } diff --git a/app/tabs/settings/components/DraggableRowList.tsx b/app/tabs/settings/components/DraggableRowList.tsx index 515781f..6e9e76a 100644 --- a/app/tabs/settings/components/DraggableRowList.tsx +++ b/app/tabs/settings/components/DraggableRowList.tsx @@ -1,7 +1,9 @@ import React, { useState } from "react"; -import { View, Text, TouchableOpacity, Switch } from "react-native"; +import { View, Pressable } from "react-native"; import { KeyboardRow, KeyConfig } from "@/types/keyboard"; -import { GripVertical } from "lucide-react-native"; +import { GripVertical, ChevronRight } from "lucide-react-native"; +import { Text, FakeSwitch } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface RenderRowItemProps { item: KeyboardRow; @@ -15,74 +17,62 @@ interface RenderRowItemProps { onToggleExpand: (rowId: string) => void; } -export function renderRowItem({ +export function renderRowItem(props: RenderRowItemProps) { + return ; +} + +function RowItem({ item, drag, isActive, onToggleVisibility, - onRemoveKey, - onReorderKeys, - onAddKeyToRow, expandedRowId, onToggleExpand, }: RenderRowItemProps) { + const color = useThemeColor(); const isExpanded = expandedRowId === item.id; return ( - - + - - - - + + - onToggleExpand(item.id)} disabled={isActive} - className="flex-1 flex-row items-center" - activeOpacity={0.6} + className="flex-1 flex-row items-center py-2.5 active:opacity-70" > - - + + {item.label} - - {item.keys.length} keys • {item.category} + + {item.keys.length} keys · {item.category} + + + + - - {isExpanded ? "▼" : "▶"} - - - - - onToggleVisibility(item.id)} - trackColor={{ false: "#3f3f46", true: "#f59145" }} - thumbColor={item.visible ? "#ffffff" : "#9ca3af"} + + onToggleVisibility(item.id)} /> diff --git a/app/tabs/settings/components/KeySelector.tsx b/app/tabs/settings/components/KeySelector.tsx index bbfafcb..e255a33 100644 --- a/app/tabs/settings/components/KeySelector.tsx +++ b/app/tabs/settings/components/KeySelector.tsx @@ -1,15 +1,16 @@ import React, { useState, useMemo } from "react"; import { View, - Text, Modal, - TouchableOpacity, ScrollView, - TextInput, + Pressable, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { X } from "lucide-react-native"; import { KeyConfig, KeyCategory } from "@/types/keyboard"; import { ALL_KEYS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; +import { Text, Button, Input, Label } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface KeySelectorProps { visible: boolean; @@ -19,7 +20,8 @@ interface KeySelectorProps { title?: string; } -const CATEGORIES: { id: KeyCategory; label: string }[] = [ +const CATEGORIES: { id: KeyCategory | "all"; label: string }[] = [ + { id: "all", label: "All" }, { id: "modifier", label: "Modifiers" }, { id: "arrow", label: "Arrows" }, { id: "navigation", label: "Navigation" }, @@ -40,9 +42,8 @@ export default function KeySelector({ title = "Add Key", }: KeySelectorProps) { const insets = useSafeAreaInsets(); - const [selectedCategory, setSelectedCategory] = useState( - "all", - ); + const color = useThemeColor(); + const [selectedCategory, setSelectedCategory] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); const allKeysArray = useMemo(() => Object.values(ALL_KEYS), []); @@ -64,15 +65,9 @@ export default function KeySelector({ ); } - keys = keys.filter((key) => !excludeKeys.includes(key.id)); - - return keys; + return keys.filter((key) => !excludeKeys.includes(key.id)); }, [allKeysArray, selectedCategory, searchQuery, excludeKeys]); - const handleSelectKey = (key: KeyConfig) => { - onSelectKey(key); - }; - return ( - - - - {title} - - - Done - - - + + {/* Header */} + + + {title} + + + + - - + - + {/* Category filter */} + - setSelectedCategory("all")} - className={`px-4 py-3 mr-2 ${ - selectedCategory === "all" ? "border-b-2 border-green-500" : "" - }`} - > - - All - - - {CATEGORIES.map((cat) => ( - setSelectedCategory(cat.id)} - className={`px-4 py-3 mr-2 ${ - selectedCategory === cat.id - ? "border-b-2 border-green-500" - : "" - }`} - > - { + const isActive = selectedCategory === cat.id; + return ( + setSelectedCategory(cat.id)} + className={`px-3 py-2.5 mr-1 border-b-2 ${isActive ? "border-accent-brand" : "border-transparent"}`} > - {cat.label} - - - ))} + + {cat.label} + + + ); + })} - + {/* Key list */} + {filteredKeys.length === 0 ? ( - - - {searchQuery - ? "No keys found matching your search" - : "No keys available"} + + + {searchQuery ? "No keys match your search" : "No keys available"} ) : ( - - {filteredKeys.map((key) => ( - handleSelectKey(key)} - className="bg-[#1a1a1a] border border-[#303032] rounded-lg p-4 flex-row items-center justify-between" - > - - - - - {key.label} - - - - {key.category} + filteredKeys.map((key) => ( + onSelectKey(key)} + className="bg-card border border-border flex-row items-center px-3 py-2.5 active:opacity-70" + > + + + + + {key.label} - {key.description && ( - - {key.description} - - )} - - - - Add + + {key.category} - - ))} - + {key.description ? ( + + {key.description} + + ) : null} + + + + )) )} diff --git a/app/tabs/settings/components/UnifiedDraggableList.tsx b/app/tabs/settings/components/UnifiedDraggableList.tsx index 42eaa2e..4870130 100644 --- a/app/tabs/settings/components/UnifiedDraggableList.tsx +++ b/app/tabs/settings/components/UnifiedDraggableList.tsx @@ -1,9 +1,10 @@ import React from "react"; -import { View, Text, TouchableOpacity } from "react-native"; +import { View, Pressable } from "react-native"; import DraggableFlatList, { RenderItemParams, ScaleDecorator, } from "react-native-draggable-flatlist"; +import { Text, Button, Label } from "@/app/components/ui"; export type UnifiedListItem = | { @@ -67,57 +68,41 @@ export default function UnifiedDraggableList({ item, drag, isActive, - getIndex, }: RenderItemParams) => { if (item.type === "header") { return ( - - - - - {item.title} + + + + {item.subtitle ? ( + + {item.subtitle} - {item.subtitle && ( - - {item.subtitle} - - )} - - {item.onAddPress && ( - - - {item.addButtonLabel || "+ Add"} - - - )} + ) : null} + {item.onAddPress ? ( + + ) : null} ); } if (item.type === "draggable-key") { const isRowKey = item.rowId !== undefined; - return ( - - - {item.renderItem( - item.data, - () => onRemoveKey?.(item.id, item.section), - drag, - isActive, - )} - + + {item.renderItem( + item.data, + () => onRemoveKey?.(item.id, item.section), + drag, + isActive, + )} ); @@ -135,60 +120,43 @@ export default function UnifiedDraggableList({ if (item.type === "row-keys-header") { return ( - - - + + + Keys in this row - {item.onAddPress && ( - - - + Add Key - - - )} + {item.onAddPress ? ( + + ) : null} ); } if (item.type === "button") { - const isDanger = item.variant === "danger"; return ( - - - {item.label} - - + {item.label} + ); } if (item.type === "spacer") { const isRowClose = item.id.startsWith("row-close-"); - if (isRowClose) { return ( ); } - return ; } From 89bd5f62fa4fb97bcefc390b1ca9a23842ebd94a Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 29 May 2026 16:57:26 -0500 Subject: [PATCH 12/35] fix: active server not updating unless app restarts --- app/tabs/settings/Settings.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/tabs/settings/Settings.tsx b/app/tabs/settings/Settings.tsx index db6df87..3a6e9ab 100644 --- a/app/tabs/settings/Settings.tsx +++ b/app/tabs/settings/Settings.tsx @@ -45,7 +45,8 @@ import { toast } from "@/app/utils/toast"; export default function Settings() { const router = useRouter(); const color = useThemeColor(); - const { isAuthenticated, setAuthenticated, openAuthFlow } = useAppContext(); + const { isAuthenticated, setAuthenticated, openAuthFlow, authFlowVisible } = + useAppContext(); const { clearAllSessions } = useTerminalSessions(); const { theme, setTheme, accent, setAccent } = useTheme(); const appLock = useAppLock(); @@ -67,7 +68,11 @@ export default function Settings() { // Re-auth gate shown before disabling app lock. const [reauth, setReauth] = useState(null); + // Re-read on auth changes and whenever the auth flow closes — the user may + // have just changed the active server inside it (which leaves isAuthenticated + // unchanged, so we can't rely on that alone). useEffect(() => { + if (authFlowVisible) return; setServerUrl(getCurrentServerUrl() ?? ""); if (!isAuthenticated) { setUsername("—"); @@ -85,7 +90,7 @@ export default function Settings() { getVersionInfo() .then((v) => setVersion(v?.localVersion ?? v?.version ?? "")) .catch(() => {}); - }, [isAuthenticated]); + }, [isAuthenticated, authFlowVisible]); const toggle = (id: string) => setOpen((o) => (o === id ? null : id)); From d4b7c68ffb2a61c55d9a430ddfe2cb6527b5ba1a Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 30 May 2026 01:23:23 -0500 Subject: [PATCH 13/35] feat: add version in settings --- app.json | 2 +- app/tabs/settings/Settings.tsx | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app.json b/app.json index 454f837..7ee98a0 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Termix", "slug": "termix", - "version": "1.3.2", + "version": "1.4.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "termix-mobile", diff --git a/app/tabs/settings/Settings.tsx b/app/tabs/settings/Settings.tsx index 3a6e9ab..f709038 100644 --- a/app/tabs/settings/Settings.tsx +++ b/app/tabs/settings/Settings.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { ScrollView, View, Pressable } from "react-native"; import { useRouter } from "expo-router"; +import Constants from "expo-constants"; import { User, Palette, @@ -45,6 +46,7 @@ import { toast } from "@/app/utils/toast"; export default function Settings() { const router = useRouter(); const color = useThemeColor(); + const appVersion = Constants.expoConfig?.version ?? ""; const { isAuthenticated, setAuthenticated, openAuthFlow, authFlowVisible } = useAppContext(); const { clearAllSessions } = useTerminalSessions(); @@ -399,6 +401,11 @@ export default function Settings() { + + {/* Version footer */} + + {appVersion ? `Termix Mobile v${appVersion}` : "Termix Mobile"} +
{/* App-lock PIN setup dialog (two-step) */} diff --git a/package-lock.json b/package-lock.json index f1ff942..879a573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix-mobile", - "version": "1.3.2", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix-mobile", - "version": "1.3.2", + "version": "1.4.0", "dependencies": { "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo/metro-runtime": "~6.1.2", diff --git a/package.json b/package.json index aa378c3..1096dc3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix-mobile", "main": "expo-router/entry", - "version": "1.3.2", + "version": "1.4.0", "scripts": { "start": "expo start", "android": "expo run:android", From f087cd902583466aaa69ec2d710a1cd192b076ee Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 30 May 2026 02:45:15 -0500 Subject: [PATCH 14/35] feat: improve host page UI (updated host form and added credential management) --- app/components/ui/Input.tsx | 18 +- app/tabs/hosts/CredentialForm.tsx | 584 +++++++++++++++++++ app/tabs/hosts/CredentialListModal.tsx | 328 +++++++++++ app/tabs/hosts/HostActionSheet.tsx | 168 +++--- app/tabs/hosts/HostForm.tsx | 748 ++++++++++++++++++------- app/tabs/hosts/Hosts.tsx | 615 +++++++++++++++----- app/tabs/hosts/QuickConnect.tsx | 174 +++--- app/tabs/hosts/navigation/Folder.tsx | 141 +++-- app/tabs/hosts/navigation/Host.tsx | 135 ++++- app/tabs/hosts/navigation/hostTree.ts | 313 +++++++++++ package-lock.json | 10 + package.json | 1 + types/index.ts | 16 + 13 files changed, 2676 insertions(+), 575 deletions(-) create mode 100644 app/tabs/hosts/CredentialForm.tsx create mode 100644 app/tabs/hosts/CredentialListModal.tsx create mode 100644 app/tabs/hosts/navigation/hostTree.ts diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx index cd75203..42e9109 100644 --- a/app/components/ui/Input.tsx +++ b/app/components/ui/Input.tsx @@ -13,21 +13,33 @@ interface InputProps extends TextInputProps { } export const Input = forwardRef(function Input( - { className, leading, trailing, containerClassName, style, ...props }, + { className, leading, trailing, containerClassName, style, multiline, ...props }, ref, ) { const placeholderColor = useThemeColor()("muted-foreground", 0.7); + // Multiline grows with content, so the container can't be a fixed-height + // centered row — it must allow height and top-align its children. Single-line + // keeps the original fixed 40px row. + const layout = multiline + ? "flex-row items-start gap-2 min-h-10 py-2 px-2.5" + : "flex-row items-center gap-2 h-10 px-2.5"; + return ( {leading ? {leading} : null} {trailing ? {trailing} : null} diff --git a/app/tabs/hosts/CredentialForm.tsx b/app/tabs/hosts/CredentialForm.tsx new file mode 100644 index 0000000..f416b70 --- /dev/null +++ b/app/tabs/hosts/CredentialForm.tsx @@ -0,0 +1,584 @@ +import { useEffect, useState } from "react"; +import { Modal, View, ScrollView, Pressable } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { X, Upload, Copy } from "lucide-react-native"; +import * as DocumentPicker from "expo-document-picker"; +import * as Clipboard from "expo-clipboard"; +import { Credential, CredentialData } from "@/types"; +import { + getCredentialDetails, + createCredential, + updateCredential, + generateKeyPair, + generatePublicKeyFromPrivate, +} from "@/app/main-axios"; +import { + Text, + Input, + Button, + Label, + SegmentedControl, + AccordionSection, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; + +type TabId = "general" | "auth"; +type GeneratingKey = "ed25519" | "ecdsa" | "rsa" | null; + +interface CredentialFormState { + name: string; + description: string; + folder: string; + tags: string; + authType: "password" | "key"; + username: string; + password: string; + key: string; + publicKey: string; + keyPassword: string; + certPublicKey: string; +} + +const EMPTY: CredentialFormState = { + name: "", + description: "", + folder: "", + tags: "", + authType: "password", + username: "", + password: "", + key: "", + publicKey: "", + keyPassword: "", + certPublicKey: "", +}; + +const TABS: { id: TabId; label: string }[] = [ + { id: "general", label: "General" }, + { id: "auth", label: "Auth" }, +]; + +export default function CredentialForm({ + visible, + credential, + onClose, + onSaved, +}: { + visible: boolean; + credential: Credential | null; + onClose: () => void; + onSaved: () => void; +}) { + const insets = useSafeAreaInsets(); + const color = useThemeColor(); + const [form, setForm] = useState(EMPTY); + const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState("general"); + const [keyExistsOnServer, setKeyExistsOnServer] = useState(false); + const [generatingKey, setGeneratingKey] = useState(null); + const [generatingPublicKey, setGeneratingPublicKey] = useState(false); + const isEdit = !!credential; + + const set = (k: K, v: CredentialFormState[K]) => + setForm((f) => ({ ...f, [k]: v })); + + useEffect(() => { + if (!visible) return; + setActiveTab("general"); + setKeyExistsOnServer(false); + if (!credential) { + setForm(EMPTY); + return; + } + setForm({ + ...EMPTY, + name: credential.name ?? "", + description: credential.description ?? "", + folder: credential.folder ?? "", + tags: (credential.tags ?? []).join(", "), + authType: credential.authType ?? "password", + username: credential.username ?? "", + publicKey: credential.publicKey ?? "", + }); + getCredentialDetails(credential.id) + .then((details: any) => { + if (!details) return; + setForm((f) => ({ + ...f, + username: details.username ?? f.username, + password: details.password ?? "", + publicKey: details.publicKey ?? details.public_key ?? f.publicKey, + keyPassword: details.keyPassword ?? details.key_password ?? "", + certPublicKey: details.certPublicKey ?? "", + })); + const hasKey = !!(details.key || details.private_key || details.hasKey); + setKeyExistsOnServer(hasKey); + }) + .catch(() => {}); + }, [visible, credential]); + + const pickPrivateKeyFile = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true }); + if (result.canceled || !result.assets?.[0]) return; + const text = await fetch(result.assets[0].uri).then((r) => r.text()); + set("key", text.trim()); + setKeyExistsOnServer(false); + toast.success(`Loaded ${result.assets[0].name}`); + } catch { + toast.error("Could not read key file"); + } + }; + + const pickCertFile = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true }); + if (result.canceled || !result.assets?.[0]) return; + const text = await fetch(result.assets[0].uri).then((r) => r.text()); + set("certPublicKey", text.trim()); + toast.success(`Loaded ${result.assets[0].name}`); + } catch { + toast.error("Could not read certificate file"); + } + }; + + const handleGenerateKeyPair = async ( + keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", + keySize?: number, + label?: GeneratingKey, + ) => { + const id = label ?? "ed25519"; + setGeneratingKey(id); + try { + const result = await generateKeyPair( + keyType, + keySize, + form.keyPassword || undefined, + ); + const priv = result.privateKey ?? result.private_key ?? ""; + const pub = result.publicKey ?? result.public_key ?? ""; + setForm((f) => ({ ...f, key: priv, publicKey: pub })); + setKeyExistsOnServer(false); + toast.success("Key pair generated"); + } catch (e: any) { + toast.error(e?.message ?? "Key generation failed"); + } finally { + setGeneratingKey(null); + } + }; + + const handleGeneratePublicKey = async () => { + if (!form.key && !keyExistsOnServer) { + toast.error("Enter a private key first"); + return; + } + if (!form.key && keyExistsOnServer) { + toast.error("Save the credential first, then regenerate"); + return; + } + setGeneratingPublicKey(true); + try { + const result = await generatePublicKeyFromPrivate( + form.key, + form.keyPassword || undefined, + ); + set("publicKey", result.publicKey ?? result.public_key ?? ""); + toast.success("Public key generated"); + } catch (e: any) { + toast.error(e?.message ?? "Failed to generate public key"); + } finally { + setGeneratingPublicKey(false); + } + }; + + const handleSave = async () => { + if (!form.name.trim()) { + toast.error("Name is required"); + return; + } + + const payload: CredentialData = { + name: form.name.trim(), + description: form.description.trim() || undefined, + folder: form.folder.trim() || undefined, + tags: form.tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + authType: form.authType, + username: form.username.trim(), + }; + + if (form.authType === "password") { + if (form.password) payload.password = form.password; + } else { + if (form.key) payload.key = form.key; + if (form.publicKey) payload.publicKey = form.publicKey; + if (form.keyPassword) payload.keyPassword = form.keyPassword; + if (form.certPublicKey) (payload as any).certPublicKey = form.certPublicKey; + } + + setSaving(true); + try { + if (isEdit && credential) { + await updateCredential(credential.id, payload); + toast.success("Credential updated"); + } else { + await createCredential(payload); + toast.success("Credential created"); + } + onSaved(); + } catch (e: any) { + toast.error(e?.message || "Failed to save credential"); + } finally { + setSaving(false); + } + }; + + return ( + + + {/* Header */} + + + + + Cancel + + + + {isEdit ? "Edit Credential" : "New Credential"} + + + + + {/* Tab strip */} + + + {TABS.map((tab) => { + const active = tab.id === activeTab; + return ( + setActiveTab(tab.id)} + className={`px-3.5 py-1.5 border ${ + active + ? "bg-accent-brand/10 border-accent-brand/40" + : "border-border active:bg-muted/40" + }`} + > + + {tab.label} + + + ); + })} + + + + + {/* General Tab */} + {activeTab === "general" ? ( +
+ + set("name", v)} + placeholder="e.g. Production SSH Key" + /> + + + set("description", v)} + placeholder="Optional details…" + /> + + + set("folder", v)} + placeholder="e.g. Server Keys" + /> + + + set("tags", v)} + placeholder="production, linux" + autoCapitalize="none" + /> + +
+ ) : null} + + {/* Auth Tab */} + {activeTab === "auth" ? ( + <> +
+ + value={form.authType} + onChange={(v) => set("authType", v)} + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "SSH Key" }, + ]} + /> +
+ + {/* Password auth */} + {form.authType === "password" ? ( +
+ + set("username", v)} + placeholder="e.g. root or deploy" + autoCapitalize="none" + autoCorrect={false} + /> + + + set("password", v)} + secureTextEntry + placeholder={isEdit ? "•••••• (unchanged if blank)" : "Password"} + autoCapitalize="none" + /> + +
+ ) : null} + + {/* SSH Key auth */} + {form.authType === "key" ? ( + <> +
+ + set("username", v)} + placeholder="e.g. root or deploy" + autoCapitalize="none" + autoCorrect={false} + /> + +
+ +
+ + + + Generate new key pair + + + Replaces current private and public key + + + + + + + + +
+ +
+ + + + + set("key", v)} + multiline + placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" + autoCapitalize="none" + autoCorrect={false} + style={{ minHeight: 100 }} + /> + {isEdit && keyExistsOnServer && form.key === "" ? ( + + Key saved — paste or upload to replace + + ) : null} +
+ +
+ + + + + + + + set("publicKey", v)} + multiline + placeholder="ssh-ed25519 AAAA…" + autoCapitalize="none" + autoCorrect={false} + style={{ minHeight: 60 }} + /> +
+ +
+ + set("keyPassword", v)} + secureTextEntry + placeholder="Passphrase (optional)" + autoCapitalize="none" + /> + +
+ + + + + + Certificate signed by a CA for certificate-based auth + + + + set("certPublicKey", v)} + multiline + placeholder="ssh-ed25519-cert-v01@openssh.com AAAA…" + autoCapitalize="none" + autoCorrect={false} + style={{ minHeight: 60 }} + /> + + + + ) : null} + + ) : null} +
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/app/tabs/hosts/CredentialListModal.tsx b/app/tabs/hosts/CredentialListModal.tsx new file mode 100644 index 0000000..130667a --- /dev/null +++ b/app/tabs/hosts/CredentialListModal.tsx @@ -0,0 +1,328 @@ +import { useEffect, useState, useMemo } from "react"; +import { + Modal, + View, + ScrollView, + Pressable, + ActivityIndicator, + RefreshControl, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + X, + Plus, + Search, + KeyRound, + Lock, + Pencil, + Trash2, + ChevronRight, +} from "lucide-react-native"; +import { Credential } from "@/types"; +import { getCredentials, deleteCredential } from "@/app/main-axios"; +import { + Text, + Input, + Button, + BottomSheet, + SheetRow, + Dialog, +} from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import CredentialForm from "@/app/tabs/hosts/CredentialForm"; + +export default function CredentialListModal({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const insets = useSafeAreaInsets(); + const color = useThemeColor(); + + const [credentials, setCredentials] = useState([]); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [sheetCredential, setSheetCredential] = useState(null); + const [formCredential, setFormCredential] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = async (isRefresh = false) => { + if (isRefresh) setRefreshing(true); + else setLoading(true); + try { + const res = await getCredentials(); + const list: Credential[] = Array.isArray(res) ? res : (res?.credentials ?? []); + setCredentials(list); + } catch (e: any) { + toast.error(e?.message || "Failed to load credentials"); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + if (!visible) return; + setSearchQuery(""); + load(); + }, [visible]); + + const filtered = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return credentials; + return credentials.filter( + (c) => + c.name.toLowerCase().includes(q) || + c.username?.toLowerCase().includes(q) || + c.folder?.toLowerCase().includes(q) || + c.tags?.some((t) => t.toLowerCase().includes(q)), + ); + }, [credentials, searchQuery]); + + const openCreate = () => { + setFormCredential(null); + setFormOpen(true); + }; + + const openEdit = (c: Credential) => { + setFormCredential(c); + setFormOpen(true); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await deleteCredential(deleteTarget.id); + toast.success(`Deleted "${deleteTarget.name}"`); + setDeleteTarget(null); + load(true); + } catch (e: any) { + toast.error(e?.message || "Failed to delete credential"); + } finally { + setDeleting(false); + } + }; + + return ( + + + {/* Header */} + + + + + Cancel + + + + Credentials + + + + + {/* Search bar */} + + } + trailing={ + searchQuery ? ( + setSearchQuery("")} hitSlop={8}> + + + ) : undefined + } + /> + + + {/* List */} + {loading ? ( + + + + Loading credentials… + + + ) : ( + load(true)} + tintColor={color("accent-brand")} + /> + } + keyboardShouldPersistTaps="handled" + > + {filtered.length === 0 ? ( + + + {searchQuery.trim() + ? "No credentials match your search" + : "No credentials yet"} + + {!searchQuery.trim() ? ( + + ) : null} + + ) : ( + filtered.map((c) => ( + setSheetCredential(c)} + color={color} + /> + )) + )} + + )} + + + {/* Action bottom sheet */} + setSheetCredential(null)} + > + + + {sheetCredential?.name} + + + {sheetCredential?.authType === "key" ? "SSH Key" : "Password"} + {sheetCredential?.username ? ` · ${sheetCredential.username}` : ""} + + + } + label="Edit" + onPress={() => { + const c = sheetCredential!; + setSheetCredential(null); + openEdit(c); + }} + /> + } + label="Delete" + destructive + onPress={() => { + setDeleteTarget(sheetCredential); + setSheetCredential(null); + }} + /> + + + {/* Delete confirmation dialog */} + setDeleteTarget(null)} + title="Delete Credential" + description={`"${deleteTarget?.name}" will be permanently deleted. Hosts using it will lose their saved credentials.`} + icon={} + footer={ + <> + + + + } + /> + + {/* Create / edit form stacked on top */} + setFormOpen(false)} + onSaved={() => { + setFormOpen(false); + load(true); + }} + /> + + ); +} + +function CredentialRow({ + credential: c, + onPress, + color, +}: { + credential: Credential; + onPress: () => void; + color: (key: string) => string | undefined; +}) { + const subtitle = [c.username, c.folder].filter(Boolean).join(" · "); + + return ( + + + {c.authType === "key" ? ( + + ) : ( + + )} + + + + {c.name} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {c.usageCount > 0 ? ( + + {c.usageCount} host{c.usageCount !== 1 ? "s" : ""} + + ) : null} + + + ); +} diff --git a/app/tabs/hosts/HostActionSheet.tsx b/app/tabs/hosts/HostActionSheet.tsx index f1afb67..e498be7 100644 --- a/app/tabs/hosts/HostActionSheet.tsx +++ b/app/tabs/hosts/HostActionSheet.tsx @@ -1,21 +1,26 @@ import { View, ScrollView } from "react-native"; +import * as Clipboard from "expo-clipboard"; import { - SquareTerminal, - FolderOpen, - Activity, + Terminal, + FolderSearch, + Server, Network, - Container, + Box, Monitor, + MousePointerClick, + MessagesSquare, Pencil, - Pin, - PinOff, + Copy, + CopyPlus, Trash2, } from "lucide-react-native"; import { SSHHost } from "@/types"; +import type { HostMetrics } from "@/app/tabs/hosts/navigation/Host"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import type { SessionType } from "@/app/contexts/TerminalSessionsContext"; import { BottomSheet, SheetRow, Text } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; function parseStatsConfig(host: SSHHost): StatsConfig { @@ -26,27 +31,38 @@ function parseStatsConfig(host: SSHHost): StatsConfig { } } +/** + * Whether the host speaks SSH. The redesigned multi-protocol backend sets + * `enableSsh`; legacy SSH-only hosts omit it, so undefined means SSH-enabled + * (matches the web's getSshActions gating). + */ +function isSshHost(host: SSHHost): boolean { + if (host.enableSsh != null) return host.enableSsh; + return !host.enableRdp && !host.enableVnc && !host.enableTelnet; +} + export function HostActionSheet({ host, status, + metrics, visible, onClose, onEdit, - onTogglePin, + onClone, onDelete, }: { host: SSHHost | null; status: "online" | "offline" | "unknown"; + metrics?: HostMetrics; visible: boolean; onClose: () => void; onEdit: (host: SSHHost) => void; - onTogglePin: (host: SSHHost) => void; + onClone: (host: SSHHost) => void; onDelete: (host: SSHHost) => void; }) { const { navigateToSessions } = useTerminalSessions(); const color = useThemeColor(); const iconColor = color("foreground") ?? "#fafafa"; - const accent = color("accent-brand") ?? "#f59145"; if (!host) return null; @@ -55,9 +71,46 @@ export function HostActionSheet({ onClose(); }; - const statsEnabled = parseStatsConfig(host).metricsEnabled; - const hasRemoteDesktop = - host.enableRdp || host.enableVnc || host.enableTelnet; + const copyAddress = async () => { + await Clipboard.setStringAsync( + `${host.username ? `${host.username}@` : ""}${host.ip}`, + ); + toast.success("Address copied"); + onClose(); + }; + + const ssh = isSshHost(host); + const metricsEnabled = ssh && parseStatsConfig(host).metricsEnabled !== false; + + // SSH-gated connection actions (mirrors the web's getSshActions). + const sshActions = [ + ssh && host.enableTerminal !== false + ? { type: "terminal" as SessionType, icon: Terminal, label: "Terminal" } + : null, + ssh && host.enableFileManager + ? { type: "filemanager" as SessionType, icon: FolderSearch, label: "File Manager" } + : null, + ssh && host.enableDocker + ? { type: "docker" as SessionType, icon: Box, label: "Docker" } + : null, + ssh && + host.enableTunnel && + host.tunnelConnections && + host.tunnelConnections.length > 0 + ? { type: "tunnel" as SessionType, icon: Network, label: "Tunnels" } + : null, + metricsEnabled + ? { type: "stats" as SessionType, icon: Server, label: "Server Stats" } + : null, + ].filter(Boolean) as { type: SessionType; icon: typeof Terminal; label: string }[]; + + // Separate protocol actions (RDP / VNC / Telnet), each with its own icon — + // matches the web rather than lumping them into one "Remote Desktop" row. + const protocolActions = [ + host.enableRdp ? { icon: Monitor, label: "RDP" } : null, + host.enableVnc ? { icon: MousePointerClick, label: "VNC" } : null, + host.enableTelnet ? { icon: MessagesSquare, label: "Telnet" } : null, + ].filter(Boolean) as { icon: typeof Monitor; label: string }[]; const dotColor = status === "online" ? "#22c55e" : status === "offline" ? "#ef4444" : "#9ca3af"; @@ -80,57 +133,47 @@ export function HostActionSheet({ {host.port ? `:${host.port}` : ""}
+ {status === "online" && + metrics && + (metrics.cpu != null || metrics.ram != null) ? ( + + {metrics.cpu != null ? ( + + CPU {Math.round(metrics.cpu)}% + + ) : null} + {metrics.ram != null ? ( + + RAM {Math.round(metrics.ram)}% + + ) : null} + + ) : null}
- - {host.enableTerminal !== false ? ( - } - label="Terminal" - onPress={() => open("terminal")} - /> - ) : null} - {host.enableFileManager ? ( - } - label="File Manager" - onPress={() => open("filemanager")} - /> - ) : null} - {statsEnabled ? ( - } - label="Server Stats" - onPress={() => open("stats")} - /> - ) : null} - {host.enableTunnel && - host.tunnelConnections && - host.tunnelConnections.length > 0 ? ( - } - label="Tunnels" - onPress={() => open("tunnel")} - /> - ) : null} - {host.enableDocker ? ( + + {/* Connection actions (SSH features + protocols) */} + {sshActions.map(({ type, icon: Icon, label }) => ( } - label="Docker" - onPress={() => open("docker")} + key={type} + icon={} + label={label} + onPress={() => open(type)} /> - ) : null} - {hasRemoteDesktop ? ( + ))} + {protocolActions.map(({ icon: Icon, label }) => ( } - label="Remote Desktop" + key={label} + icon={} + label={label} onPress={() => open("remoteDesktop")} /> - ) : null} + ))} - {/* Management actions */} + {/* Management actions (no pin — matches the web). Rows sit flush with + the connection group; each row's own bottom border separates them. */} } + icon={} label="Edit Host" onPress={() => { onEdit(host); @@ -138,16 +181,15 @@ export function HostActionSheet({ }} /> - ) : ( - - ) - } - label={host.pin ? "Unpin" : "Pin"} + icon={} + label="Copy Address" + onPress={copyAddress} + /> + } + label="Clone Host" onPress={() => { - onTogglePin(host); + onClone(host); onClose(); }} /> diff --git a/app/tabs/hosts/HostForm.tsx b/app/tabs/hosts/HostForm.tsx index 68830b6..6054d8a 100644 --- a/app/tabs/hosts/HostForm.tsx +++ b/app/tabs/hosts/HostForm.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Modal, View, ScrollView, Pressable } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { X } from "lucide-react-native"; +import { X, Upload } from "lucide-react-native"; +import * as DocumentPicker from "expo-document-picker"; import { SSHHost, SSHHostData, Credential } from "@/types"; import { createSSHHost, @@ -16,22 +17,25 @@ import { Label, FakeSwitch, SettingRow, - AccordionSection, SegmentedControl, } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; import { toast } from "@/app/utils/toast"; type AuthType = "password" | "key" | "credential" | "none"; +type TabId = "general" | "ssh" | "rdp" | "vnc" | "telnet"; interface FormState { name: string; ip: string; port: string; + // Protocols + enableSsh: boolean; + enableRdp: boolean; + enableVnc: boolean; + enableTelnet: boolean; + // SSH username: string; - folder: string; - tags: string; - pin: boolean; authType: AuthType; password: string; key: string; @@ -42,20 +46,35 @@ interface FormState { enableFileManager: boolean; enableDocker: boolean; defaultPath: string; + // RDP + rdpUser: string; + rdpPassword: string; + rdpDomain: string; + rdpPort: string; + // VNC + vncUser: string; + vncPassword: string; + vncPort: string; + // Telnet + telnetUser: string; + telnetPassword: string; + telnetPort: string; + // Organization + folder: string; + tags: string; + pin: boolean; notes: string; - enableRdp: boolean; - enableVnc: boolean; - enableTelnet: boolean; } const EMPTY: FormState = { name: "", ip: "", port: "22", + enableSsh: true, + enableRdp: false, + enableVnc: false, + enableTelnet: false, username: "", - folder: "", - tags: "", - pin: false, authType: "password", password: "", key: "", @@ -66,10 +85,20 @@ const EMPTY: FormState = { enableFileManager: true, enableDocker: false, defaultPath: "/", + rdpUser: "Administrator", + rdpPassword: "", + rdpDomain: "", + rdpPort: "3389", + vncUser: "", + vncPassword: "", + vncPort: "5900", + telnetUser: "", + telnetPassword: "", + telnetPort: "23", + folder: "", + tags: "", + pin: false, notes: "", - enableRdp: false, - enableVnc: false, - enableTelnet: false, }; export default function HostForm({ @@ -88,6 +117,7 @@ export default function HostForm({ const [form, setForm] = useState(EMPTY); const [credentials, setCredentials] = useState([]); const [saving, setSaving] = useState(false); + const [activeTab, setActiveTab] = useState("general"); const isEdit = !!host; const set = (k: K, v: FormState[K]) => @@ -104,9 +134,10 @@ export default function HostForm({ .catch(() => {}); }, [visible]); - // Populate the form when opening (prefill secrets on edit). + // Populate the form when opening (prefill secrets on edit) and reset the tab. useEffect(() => { if (!visible) return; + setActiveTab("general"); if (!host) { setForm(EMPTY); return; @@ -117,10 +148,11 @@ export default function HostForm({ name: host.name ?? "", ip: host.ip ?? "", port: String(host.port ?? host.sshPort ?? 22), + enableSsh: host.enableSsh !== false, + enableRdp: !!host.enableRdp, + enableVnc: !!host.enableVnc, + enableTelnet: !!host.enableTelnet, username: host.username ?? "", - folder: host.folder ?? "", - tags: (host.tags ?? []).join(", "), - pin: !!host.pin, authType: host.authType ?? "password", credentialId: host.credentialId, enableTerminal: host.enableTerminal !== false, @@ -128,10 +160,17 @@ export default function HostForm({ enableFileManager: !!host.enableFileManager, enableDocker: !!host.enableDocker, defaultPath: host.defaultPath || "/", + rdpUser: host.rdpUser ?? "Administrator", + rdpDomain: host.rdpDomain ?? "", + rdpPort: String(host.rdpPort ?? 3389), + vncUser: host.vncUser ?? "", + vncPort: String(host.vncPort ?? 5900), + telnetUser: host.telnetUser ?? "", + telnetPort: String(host.telnetPort ?? 23), + folder: host.folder ?? "", + tags: (host.tags ?? []).join(", "), + pin: !!host.pin, notes: host.notes ?? "", - enableRdp: !!host.enableRdp, - enableVnc: !!host.enableVnc, - enableTelnet: !!host.enableTelnet, }); getSSHHostWithCredentials(host.id) .then((full) => { @@ -141,18 +180,57 @@ export default function HostForm({ password: full.password ?? "", key: full.key ?? "", keyPassword: full.keyPassword ?? "", + rdpPassword: full.rdpPassword ?? "", + vncPassword: full.vncPassword ?? "", + telnetPassword: full.telnetPassword ?? "", })); }) .catch(() => {}); }, [visible, host]); + // The tab strip: General is always present; protocol tabs follow their toggle. + const tabs = useMemo<{ id: TabId; label: string }[]>(() => { + const list: { id: TabId; label: string }[] = [ + { id: "general", label: "General" }, + ]; + if (form.enableSsh) list.push({ id: "ssh", label: "SSH" }); + if (form.enableRdp) list.push({ id: "rdp", label: "RDP" }); + if (form.enableVnc) list.push({ id: "vnc", label: "VNC" }); + if (form.enableTelnet) list.push({ id: "telnet", label: "Telnet" }); + return list; + }, [form.enableSsh, form.enableRdp, form.enableVnc, form.enableTelnet]); + + // If the active tab's protocol gets disabled, fall back to General. + useEffect(() => { + if (!tabs.some((t) => t.id === activeTab)) setActiveTab("general"); + }, [tabs, activeTab]); + + const pickKeyFile = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets?.[0]) return; + const asset = result.assets[0]; + const text = await fetch(asset.uri).then((r) => r.text()); + set("key", text); + toast.success(`Loaded ${asset.name}`); + } catch { + toast.error("Could not read key file"); + } + }; + const handleSave = async () => { if (!form.ip.trim()) { toast.error("Host address is required"); return; } - if (!form.username.trim() && form.authType !== "none") { - toast.error("Username is required"); + if (!form.enableSsh && !form.enableRdp && !form.enableVnc && !form.enableTelnet) { + toast.error("Enable at least one protocol"); + return; + } + if (form.enableSsh && form.authType !== "none" && !form.username.trim()) { + toast.error("SSH username is required"); return; } @@ -179,10 +257,21 @@ export default function HostForm({ enableDocker: form.enableDocker, defaultPath: form.defaultPath, notes: form.notes, - enableSsh: true, + enableSsh: form.enableSsh, enableRdp: form.enableRdp, enableVnc: form.enableVnc, enableTelnet: form.enableTelnet, + // Protocol fields — the backend null-guards these by their enable flag. + rdpUser: form.rdpUser, + rdpPassword: form.rdpPassword, + rdpDomain: form.rdpDomain, + rdpPort: parseInt(form.rdpPort, 10) || 3389, + vncUser: form.vncUser, + vncPassword: form.vncPassword, + vncPort: parseInt(form.vncPort, 10) || 5900, + telnetUser: form.telnetUser, + telnetPassword: form.telnetPassword, + telnetPort: parseInt(form.telnetPort, 10) || 23, }; setSaving(true); @@ -212,7 +301,11 @@ export default function HostForm({ {/* Header */} - + Cancel @@ -231,78 +324,206 @@ export default function HostForm({ + {/* Tab strip */} + + + {tabs.map((tab) => { + const active = tab.id === activeTab; + return ( + setActiveTab(tab.id)} + className={`px-3.5 py-1.5 border ${ + active + ? "bg-accent-brand/10 border-accent-brand/40" + : "border-border active:bg-muted/40" + }`} + > + + {tab.label} + + + ); + })} + + + - {/* Connection */} - - - set("name", v)} placeholder="My Server" /> - - - - + {activeTab === "general" ? ( + <> + {/* Connection */} +
+ set("ip", v)} - placeholder="192.168.1.10" - autoCapitalize="none" - autoCorrect={false} + value={form.name} + onChangeText={(v) => set("name", v)} + placeholder="My Server" /> - - - + + + + set("ip", v)} + placeholder="192.168.1.10" + autoCapitalize="none" + autoCorrect={false} + /> + + + + + set("port", v.replace(/\D/g, ""))} + keyboardType="number-pad" + placeholder="22" + /> + + + +
+ + {/* Protocols */} +
+ + + set("enableSsh", v)} + /> + + + set("enableRdp", v)} + /> + + + set("enableVnc", v)} + /> + + + set("enableTelnet", v)} + /> + + + + Enable a protocol to configure it in its own tab above. + +
+ + {/* Organization */} +
+ set("port", v.replace(/\D/g, ""))} - keyboardType="number-pad" - placeholder="22" + value={form.folder} + onChangeText={(v) => set("folder", v)} + placeholder="Production" /> - - - - set("username", v)} - placeholder="root" - autoCapitalize="none" - autoCorrect={false} - /> - - + + set("tags", v)} + placeholder="web, nginx" + autoCapitalize="none" + /> + + + set("pin", v)} + /> + +
- {/* Authentication */} - - - - value={form.authType} - onChange={(v) => set("authType", v)} - options={[ - { id: "password", label: "Pass" }, - { id: "key", label: "Key" }, - { id: "credential", label: "Cred" }, - { id: "none", label: "None" }, - ]} - /> + {/* Notes */} +
+ set("notes", v)} + multiline + placeholder="Notes about this host…" + style={{ minHeight: 70 }} + /> +
+ + ) : null} - {form.authType === "password" ? ( - + {activeTab === "ssh" ? ( + <> +
+ set("password", v)} - secureTextEntry - placeholder={isEdit ? "•••••• (unchanged if blank)" : "Password"} + value={form.username} + onChangeText={(v) => set("username", v)} + placeholder="root" autoCapitalize="none" + autoCorrect={false} /> - ) : null} + + value={form.authType} + onChange={(v) => set("authType", v)} + options={[ + { id: "password", label: "Pass" }, + { id: "key", label: "Key" }, + { id: "credential", label: "Cred" }, + { id: "none", label: "None" }, + ]} + /> + + {form.authType === "password" ? ( + + set("password", v)} + secureTextEntry + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } + autoCapitalize="none" + /> + + ) : null} - {form.authType === "key" ? ( - <> - + {form.authType === "key" ? ( + <> + + + + set("key", v)} @@ -310,164 +531,250 @@ export default function HostForm({ placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autoCapitalize="none" autoCorrect={false} - style={{ minHeight: 90, textAlignVertical: "top" }} + style={{ minHeight: 100 }} /> + + set("keyPassword", v)} + secureTextEntry + placeholder="Passphrase" + autoCapitalize="none" + /> + + + ) : null} + + {form.authType === "credential" ? ( + + {credentials.length === 0 ? ( + + No saved credentials. Tap the key icon in Hosts to add one. + + ) : ( + + {credentials.map((c) => { + const selected = form.credentialId === c.id; + return ( + set("credentialId", c.id)} + className={`px-3 py-2.5 border ${selected ? "border-accent-brand/40 bg-accent-brand/10" : "border-border bg-card"}`} + > + + {c.name} + + {c.username ? ( + + {c.username} + + ) : null} + + ); + })} + + )} - + ) : null} +
+ +
+ + + set("enableTerminal", v)} + /> + + + set("enableFileManager", v)} + /> + + + set("enableTunnel", v)} + /> + + + set("enableDocker", v)} + /> + + + {form.enableFileManager ? ( + set("keyPassword", v)} - secureTextEntry - placeholder="Passphrase" + value={form.defaultPath} + onChangeText={(v) => set("defaultPath", v)} + placeholder="/" autoCapitalize="none" /> - - ) : null} + ) : null} +
+ + ) : null} - {form.authType === "credential" ? ( - - {credentials.length === 0 ? ( - - No saved credentials. Create one in the Tools tab. - - ) : ( - - {credentials.map((c) => { - const selected = form.credentialId === c.id; - return ( - set("credentialId", c.id)} - className={`px-3 py-2.5 border ${selected ? "border-accent-brand/40 bg-accent-brand/10" : "border-border bg-card"}`} - > - - {c.name} - - {c.username ? ( - - {c.username} - - ) : null} - - ); - })} - - )} - - ) : null} -
-
- - {/* Organization */} - - - + {activeTab === "rdp" ? ( +
+ set("folder", v)} - placeholder="Production" + value={form.rdpUser} + onChangeText={(v) => set("rdpUser", v)} + placeholder="Administrator" + autoCapitalize="none" + autoCorrect={false} /> - + set("tags", v)} - placeholder="web, nginx" + value={form.rdpPassword} + onChangeText={(v) => set("rdpPassword", v)} + secureTextEntry + placeholder={isEdit ? "•••••• (unchanged if blank)" : "Password"} autoCapitalize="none" /> - - set("pin", v)} /> - - - + + + + set("rdpDomain", v)} + placeholder="WORKGROUP" + autoCapitalize="none" + /> + + + + + set("rdpPort", v.replace(/\D/g, ""))} + keyboardType="number-pad" + placeholder="3389" + /> + + + + +
+ ) : null} - {/* Features */} - - - - set("enableTerminal", v)} - /> - - - set("enableFileManager", v)} - /> - - {form.enableFileManager ? ( - - set("defaultPath", v)} - placeholder="/" - autoCapitalize="none" - /> - - ) : null} - - set("enableTunnel", v)} - /> - - - set("enableDocker", v)} + {activeTab === "vnc" ? ( +
+ + set("vncUser", v)} + placeholder="vnc" + autoCapitalize="none" + autoCorrect={false} /> - - - + + + + + set("vncPassword", v)} + secureTextEntry + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } + autoCapitalize="none" + /> + + + + + set("vncPort", v.replace(/\D/g, ""))} + keyboardType="number-pad" + placeholder="5900" + /> + + + + +
+ ) : null} - {/* Protocols (remote desktop) */} - - - - set("enableRdp", v)} - /> - - - set("enableVnc", v)} - /> - - - set("enableTelnet", v)} + {activeTab === "telnet" ? ( +
+ + set("telnetUser", v)} + placeholder="admin" + autoCapitalize="none" + autoCorrect={false} /> - - - - - {/* Notes */} - - - set("notes", v)} - multiline - placeholder="Notes about this host…" - style={{ minHeight: 70, textAlignVertical: "top" }} - /> - - + + + + + set("telnetPassword", v)} + secureTextEntry + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } + autoCapitalize="none" + /> + + + + + + set("telnetPort", v.replace(/\D/g, "")) + } + keyboardType="number-pad" + placeholder="23" + /> + + + + +
+ ) : null}
); } +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} + function Field({ label, children, @@ -482,3 +789,12 @@ function Field({
); } + +function AdvancedNote() { + return ( + + Advanced display, audio, and clipboard settings are available in the + Termix web app. + + ); +} diff --git a/app/tabs/hosts/Hosts.tsx b/app/tabs/hosts/Hosts.tsx index 21481f9..37427cd 100644 --- a/app/tabs/hosts/Hosts.tsx +++ b/app/tabs/hosts/Hosts.tsx @@ -5,28 +5,35 @@ import { RefreshControl, Pressable, } from "react-native"; -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, useMemo, useEffect } from "react"; import { useFocusEffect } from "@react-navigation/native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { RefreshCw, Search, X, ArrowUpDown, + Filter, Check, Zap, + Plus, + KeyRound, } from "lucide-react-native"; -import Folder from "@/app/tabs/hosts/navigation/Folder"; +import HostTree from "@/app/tabs/hosts/navigation/Folder"; +import type { HostMetrics } from "@/app/tabs/hosts/navigation/Host"; import { HostActionSheet } from "@/app/tabs/hosts/HostActionSheet"; import HostForm from "@/app/tabs/hosts/HostForm"; import { QuickConnect } from "@/app/tabs/hosts/QuickConnect"; +import CredentialListModal from "@/app/tabs/hosts/CredentialListModal"; import { getSSHHosts, getFoldersWithStats, getAllServerStatuses, + getServerMetricsById, initializeServerConfig, getCurrentServerUrl, deleteSSHHost, - updateSSHHost, + createSSHHost, } from "@/app/main-axios"; import { SSHHost, ServerStatus } from "@/types"; import { Screen } from "@/app/components/Screen"; @@ -36,124 +43,232 @@ import { Button, BottomSheet, SheetRow, + Checkbox, } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; import { toast } from "@/app/utils/toast"; - -interface FolderData { - name: string; - color?: string; - hosts: SSHHost[]; -} - -type SortKey = - | "default" - | "name-asc" - | "name-desc" - | "status-online" - | "pinned"; +import { + SortKey, + FilterState, + DEFAULT_FILTERS, + HostStatus, + buildHostTree, + sortHostTree, + applyFilters, + filterTreeByQuery, + collectFolderPaths, + collectMatchingFolderPaths, + collectAllTags, + filtersActive, +} from "@/app/tabs/hosts/navigation/hostTree"; const SORT_OPTIONS: { id: SortKey; label: string }[] = [ { id: "default", label: "Default" }, { id: "name-asc", label: "Name (A–Z)" }, { id: "name-desc", label: "Name (Z–A)" }, + { id: "ip-asc", label: "IP (ascending)" }, + { id: "ip-desc", label: "IP (descending)" }, { id: "status-online", label: "Online first" }, + { id: "status-offline", label: "Offline first" }, { id: "pinned", label: "Pinned first" }, ]; +const FILTER_GROUPS: { + group: keyof FilterState; + title: string; + options: { value: string; label: string }[]; +}[] = [ + { + group: "status", + title: "Status", + options: [ + { value: "online", label: "Online" }, + { value: "offline", label: "Offline" }, + { value: "pinned", label: "Pinned" }, + ], + }, + { + group: "protocol", + title: "Protocol", + options: [ + { value: "ssh", label: "SSH" }, + { value: "rdp", label: "RDP" }, + { value: "vnc", label: "VNC" }, + { value: "telnet", label: "Telnet" }, + ], + }, + { + group: "features", + title: "Features", + options: [ + { value: "terminal", label: "Terminal" }, + { value: "fileManager", label: "File Manager" }, + { value: "tunnel", label: "Tunnel" }, + { value: "docker", label: "Docker" }, + ], + }, +]; + +const STORAGE_SORT = "hostSortKey"; +const STORAGE_FILTER = "hostFilterState"; +const STORAGE_EXPANDED = "hostExpandedFolders"; + export default function Hosts() { const color = useThemeColor(); - const [folders, setFolders] = useState([]); + const [hosts, setHosts] = useState([]); + const [folderColors, setFolderColors] = useState< + Record + >({}); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [serverStatuses, setServerStatuses] = useState< Record >({}); + const [metrics, setMetrics] = useState>({}); const [sortKey, setSortKey] = useState("default"); + const [filterState, setFilterState] = useState(DEFAULT_FILTERS); + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [prefsLoaded, setPrefsLoaded] = useState(false); const [showSort, setShowSort] = useState(false); + const [showFilter, setShowFilter] = useState(false); // Action sheet + form state const [sheetHost, setSheetHost] = useState(null); const [formHost, setFormHost] = useState(null); const [formOpen, setFormOpen] = useState(false); const [quickConnectOpen, setQuickConnectOpen] = useState(false); + const [credentialListOpen, setCredentialListOpen] = useState(false); const isRefreshingRef = useRef(false); + // Tracks whether the user has manually toggled folders this session, so a data + // refresh doesn't blow away their expansion choices. + const expansionInitializedRef = useRef(false); - const fetchData = useCallback(async (isRefresh = false) => { - if (isRefreshingRef.current) return; - try { - isRefreshingRef.current = true; - if (isRefresh) setRefreshing(true); - else setLoading(true); + // --- Load persisted preferences once on mount. + useEffect(() => { + (async () => { + try { + const [savedSort, savedFilter, savedExpanded] = await Promise.all([ + AsyncStorage.getItem(STORAGE_SORT), + AsyncStorage.getItem(STORAGE_FILTER), + AsyncStorage.getItem(STORAGE_EXPANDED), + ]); + if (savedSort) setSortKey(savedSort as SortKey); + if (savedFilter) setFilterState(JSON.parse(savedFilter) as FilterState); + if (savedExpanded) { + setExpandedPaths(new Set(JSON.parse(savedExpanded) as string[])); + expansionInitializedRef.current = true; + } + } catch { + // best-effort + } finally { + setPrefsLoaded(true); + } + })(); + }, []); + + const getHostStatus = useCallback( + (hostId: number): HostStatus => + serverStatuses[hostId]?.status ?? "unknown", + [serverStatuses], + ); - await initializeServerConfig(); - if (!getCurrentServerUrl()) { - toast.error("No server configured. Set one up in Settings."); + // Fetch CPU/RAM for online hosts. Never throws; failures are ignored so the + // list always renders. + const fetchMetrics = useCallback( + async ( + hostList: SSHHost[], + statuses: Record, + ) => { + const onlineIds = hostList + .filter((h) => statuses[h.id]?.status === "online") + .map((h) => h.id); + if (onlineIds.length === 0) { + setMetrics({}); return; } + const results = await Promise.allSettled( + onlineIds.map((id) => getServerMetricsById(id)), + ); + const next: Record = {}; + results.forEach((res, i) => { + if (res.status === "fulfilled" && res.value) { + next[onlineIds[i]] = { + cpu: res.value.cpu?.percent ?? null, + ram: res.value.memory?.percent ?? null, + }; + } + }); + setMetrics(next); + }, + [], + ); + + const fetchData = useCallback( + async (isRefresh = false) => { + if (isRefreshingRef.current) return; + try { + isRefreshingRef.current = true; + if (isRefresh) setRefreshing(true); + else setLoading(true); - const [hostsResult, statusesResult] = await Promise.allSettled([ - getSSHHosts(), - getAllServerStatuses(), - ]); + await initializeServerConfig(); + if (!getCurrentServerUrl()) { + toast.error("No server configured. Set one up in Settings."); + return; + } - if (hostsResult.status !== "fulfilled") throw hostsResult.reason; + const [hostsResult, statusesResult] = await Promise.allSettled([ + getSSHHosts(), + getAllServerStatuses(), + ]); - const raw = hostsResult.value as any; - const hosts: SSHHost[] = Array.isArray(raw) - ? raw - : Array.isArray(raw?.hosts) - ? raw.hosts - : []; - const statuses = - statusesResult.status === "fulfilled" ? statusesResult.value : {}; + if (hostsResult.status !== "fulfilled") throw hostsResult.reason; - let foldersData: any = null; - try { - foldersData = await getFoldersWithStats(); - } catch { - // folders are optional - } + const raw = hostsResult.value as any; + const hostList: SSHHost[] = Array.isArray(raw) + ? raw + : Array.isArray(raw?.hosts) + ? raw.hosts + : []; + const statuses = + statusesResult.status === "fulfilled" ? statusesResult.value : {}; - const folderMap = new Map(); - if (Array.isArray(foldersData)) { - foldersData.forEach((f: any) => - folderMap.set(f.name, { name: f.name, color: f.color, hosts: [] }), - ); - } + let foldersData: any = null; + try { + foldersData = await getFoldersWithStats(); + } catch { + // folders are optional + } + const colors: Record = {}; + if (Array.isArray(foldersData)) { + foldersData.forEach((f: any) => { + if (f?.name) colors[f.name] = f.color; + }); + } - hosts.forEach((host) => { - const folderName = host.folder || "No Folder"; - if (!folderMap.has(folderName)) - folderMap.set(folderName, { name: folderName, hosts: [] }); - folderMap.get(folderName)!.hosts.push(host); - }); + setHosts(hostList); + setFolderColors(colors); + setServerStatuses(statuses); - const arr = Array.from(folderMap.values()) - .filter((f) => f.hosts.length > 0) - .sort((a, b) => { - if (a.name === "No Folder") return 1; - if (b.name === "No Folder") return -1; - return a.name.localeCompare(b.name); - }); - - setFolders(arr); - setServerStatuses(statuses); - } catch (error: any) { - const isAuth = - error?.response?.status === 401 || - error?.message?.includes("Authentication required"); - if (!isAuth) { - toast.error(error?.message || "Failed to load hosts."); + // Best-effort live metrics for online hosts only. + void fetchMetrics(hostList, statuses); + } catch (error: any) { + const isAuth = + error?.response?.status === 401 || + error?.message?.includes("Authentication required"); + if (!isAuth) { + toast.error(error?.message || "Failed to load hosts."); + } + } finally { + setLoading(false); + setRefreshing(false); + isRefreshingRef.current = false; } - } finally { - setLoading(false); - setRefreshing(false); - isRefreshingRef.current = false; - } - }, []); + }, + [fetchMetrics], + ); const handleRefresh = useCallback(() => { if (!isRefreshingRef.current) fetchData(true); @@ -165,69 +280,146 @@ export default function Hosts() { }, [fetchData]), ); - const getHostStatus = ( - hostId: number, - ): "online" | "offline" | "unknown" => { - return serverStatuses[hostId]?.status ?? "unknown"; - }; + // --- Build, sort, and filter the tree. + const tree = useMemo( + () => buildHostTree(hosts, folderColors), + [hosts, folderColors], + ); - const sortHosts = (hosts: SSHHost[]): SSHHost[] => { - if (sortKey === "default") return hosts; - const copy = [...hosts]; - copy.sort((a, b) => { - switch (sortKey) { - case "name-asc": - return a.name.localeCompare(b.name); - case "name-desc": - return b.name.localeCompare(a.name); - case "status-online": - return ( - (getHostStatus(b.id) === "online" ? 1 : 0) - - (getHostStatus(a.id) === "online" ? 1 : 0) - ); - case "pinned": - return (b.pin ? 1 : 0) - (a.pin ? 1 : 0); - default: - return 0; - } + const q = searchQuery.trim().toLowerCase(); + + const visibleTree = useMemo(() => { + let nodes = applyFilters(tree, filterState, getHostStatus); + nodes = filterTreeByQuery(nodes, q); + nodes = sortHostTree(nodes, sortKey, getHostStatus); + return nodes; + }, [tree, filterState, q, sortKey, getHostStatus]); + + // --- Default expansion: expand all folders the first time hosts load. + useEffect(() => { + if (!prefsLoaded || expansionInitializedRef.current) return; + if (tree.length === 0) return; + setExpandedPaths(new Set(collectFolderPaths(tree))); + expansionInitializedRef.current = true; + }, [prefsLoaded, tree]); + + // When searching, force-expand matching folders without touching the + // persisted set. + const effectiveExpanded = useMemo(() => { + if (!q) return expandedPaths; + const next = new Set(expandedPaths); + collectMatchingFolderPaths(tree, q).forEach((p) => next.add(p)); + return next; + }, [q, expandedPaths, tree]); + + const toggleFolder = useCallback((path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + AsyncStorage.setItem( + STORAGE_EXPANDED, + JSON.stringify([...next]), + ).catch(() => {}); + return next; }); - return copy; - }; + }, []); - const q = searchQuery.trim().toLowerCase(); - const filteredFolders = folders - .map((folder) => ({ - ...folder, - hosts: sortHosts( - folder.hosts.filter( - (h) => - !q || - h.name.toLowerCase().includes(q) || - h.ip.toLowerCase().includes(q) || - (h.username ?? "").toLowerCase().includes(q) || - (h.tags ?? []).some((t) => t.toLowerCase().includes(q)), - ), - ), - })) - .filter((folder) => folder.hosts.length > 0); - - const handleTogglePin = async (host: SSHHost) => { + const handleSortChange = useCallback((key: SortKey) => { + setSortKey(key); + AsyncStorage.setItem(STORAGE_SORT, key).catch(() => {}); + setShowSort(false); + }, []); + + const toggleFilter = useCallback( + (group: keyof FilterState, value: string) => { + setFilterState((prev) => { + const arr = prev[group] as string[]; + const nextArr = arr.includes(value) + ? arr.filter((v) => v !== value) + : [...arr, value]; + const updated = { ...prev, [group]: nextArr } as FilterState; + AsyncStorage.setItem( + STORAGE_FILTER, + JSON.stringify(updated), + ).catch(() => {}); + return updated; + }); + }, + [], + ); + + const clearFilters = useCallback(() => { + setFilterState(DEFAULT_FILTERS); + AsyncStorage.setItem( + STORAGE_FILTER, + JSON.stringify(DEFAULT_FILTERS), + ).catch(() => {}); + }, []); + + const getHostMetrics = useCallback( + (hostId: number): HostMetrics | undefined => metrics[hostId], + [metrics], + ); + + const allTags = useMemo(() => collectAllTags(hosts), [hosts]); + const isFilterActive = filtersActive(filterState); + + const handleDelete = async (host: SSHHost) => { try { - await updateSSHHost(host.id, { ...(host as any), pin: !host.pin }); - toast.success(host.pin ? "Unpinned" : "Pinned"); + await deleteSSHHost(host.id); + toast.success(`Deleted ${host.name}`); fetchData(true); } catch (e: any) { - toast.error(e?.message || "Failed to update host"); + toast.error(e?.message || "Failed to delete host"); } }; - const handleDelete = async (host: SSHHost) => { + const handleClone = async (host: SSHHost) => { try { - await deleteSSHHost(host.id); - toast.success(`Deleted ${host.name}`); + await createSSHHost({ + name: `${host.name} (copy)`, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder, + tags: host.tags ?? [], + pin: false, + authType: host.authType, + password: host.password, + keyPassword: host.keyPassword, + keyType: host.keyType, + credentialId: host.credentialId ?? null, + overrideCredentialUsername: host.overrideCredentialUsername, + enableSsh: host.enableSsh, + enableRdp: host.enableRdp, + enableVnc: host.enableVnc, + enableTelnet: host.enableTelnet, + enableTerminal: host.enableTerminal, + enableTunnel: host.enableTunnel, + enableFileManager: host.enableFileManager, + enableDocker: host.enableDocker, + defaultPath: host.defaultPath ?? "/", + forceKeyboardInteractive: host.forceKeyboardInteractive, + tunnelConnections: host.tunnelConnections ?? [], + notes: host.notes, + rdpUser: host.rdpUser, + rdpPassword: host.rdpPassword, + rdpDomain: host.rdpDomain, + rdpPort: host.rdpPort, + vncUser: host.vncUser, + vncPassword: host.vncPassword, + vncPort: host.vncPort, + telnetUser: host.telnetUser, + telnetPassword: host.telnetPassword, + telnetPort: host.telnetPort, + statsConfig: host.statsConfig, + terminalConfig: host.terminalConfig, + } as any); + toast.success(`Cloned ${host.name}`); fetchData(true); } catch (e: any) { - toast.error(e?.message || "Failed to delete host"); + toast.error(e?.message || "Failed to clone host"); } }; @@ -236,17 +428,51 @@ export default function Hosts() { setFormOpen(true); }; + const openCreate = () => { + setFormHost(null); + setFormOpen(true); + }; + + const isEmpty = visibleTree.length === 0; + return ( + + ) : null}
) : ( - filteredFolders.map((folder) => ( - - )) + )} )} @@ -344,10 +578,11 @@ export default function Hosts() { setSheetHost(null)} onEdit={openEdit} - onTogglePin={handleTogglePin} + onClone={handleClone} onDelete={handleDelete} /> @@ -356,15 +591,13 @@ export default function Hosts() { visible={showSort} onClose={() => setShowSort(false)} title="Sort hosts" + scroll > {SORT_OPTIONS.map((opt) => ( { - setSortKey(opt.id); - setShowSort(false); - }} + onPress={() => handleSortChange(opt.id)} trailing={ sortKey === opt.id ? ( @@ -374,6 +607,84 @@ export default function Hosts() { ))} + {/* Filter menu */} + setShowFilter(false)} + title="Filter hosts" + scroll + > + {isFilterActive ? ( + } + label="Clear all filters" + onPress={clearFilters} + /> + ) : null} + {FILTER_GROUPS.map((grp) => ( + + + + {grp.title} + + + {grp.options.map((opt) => { + const checked = (filterState[grp.group] as string[]).includes( + opt.value, + ); + return ( + toggleFilter(grp.group, opt.value)} + className="flex-row items-center gap-3 px-4 py-3 border-b border-border/60 active:bg-muted/40" + > + toggleFilter(grp.group, opt.value)} + /> + + {opt.label} + + + ); + })} + + ))} + {allTags.length > 0 ? ( + + + + Tags + + + {allTags.map((tag) => { + const checked = filterState.tags.includes(tag); + return ( + toggleFilter("tags", tag)} + className="flex-row items-center gap-3 px-4 py-3 border-b border-border/60 active:bg-muted/40" + > + toggleFilter("tags", tag)} + /> + + {tag} + + + ); + })} + + ) : null} + + {/* Create / edit form */} setQuickConnectOpen(false)} /> + + {/* Credential manager */} + setCredentialListOpen(false)} + /> ); } diff --git a/app/tabs/hosts/QuickConnect.tsx b/app/tabs/hosts/QuickConnect.tsx index 6ac94a0..1636a4b 100644 --- a/app/tabs/hosts/QuickConnect.tsx +++ b/app/tabs/hosts/QuickConnect.tsx @@ -1,14 +1,20 @@ import { useState } from "react"; -import { View } from "react-native"; +import { + View, + ScrollView, + KeyboardAvoidingView, + Platform, +} from "react-native"; import { Zap } from "lucide-react-native"; import { SSHHost } from "@/types"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import { - Dialog, + BottomSheet, Input, Button, Label, SegmentedControl, + Text, } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; import { toast } from "@/app/utils/toast"; @@ -76,87 +82,107 @@ export function QuickConnect({ }; return ( - } - footer={ - <> - - - - } - > - - - - - + + + {/* Header */} + + + - - - setPort(v.replace(/\D/g, ""))} - keyboardType="number-pad" - /> + + + Quick Connect + + + Connect to a host without saving it. + - - - - - - value={authType} - onChange={setAuthType} - options={[ - { id: "password", label: "Password" }, - { id: "key", label: "Key" }, - ]} - /> - {authType === "password" ? ( - - - + + + + + + + + + + setPort(v.replace(/\D/g, ""))} + keyboardType="number-pad" + /> + - ) : ( + - + - )} - - + + + value={authType} + onChange={setAuthType} + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "Key" }, + ]} + /> + + {authType === "password" ? ( + + + + + ) : ( + + + + + )} + + {/* Actions */} + + + + + + + ); } diff --git a/app/tabs/hosts/navigation/Folder.tsx b/app/tabs/hosts/navigation/Folder.tsx index 1e8255a..b0c06d1 100644 --- a/app/tabs/hosts/navigation/Folder.tsx +++ b/app/tabs/hosts/navigation/Folder.tsx @@ -1,75 +1,110 @@ -import { useState } from "react"; import { View, Pressable } from "react-native"; import { ChevronRight, Folder as FolderIcon } from "lucide-react-native"; -import { SSHHost } from "@/types"; -import Host from "@/app/tabs/hosts/navigation/Host"; +import { SSHHost, HostTreeNode } from "@/types"; +import Host, { HostMetrics } from "@/app/tabs/hosts/navigation/Host"; import { Text } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { + GetHostStatus, + folderCounts, + isFolder, +} from "@/app/tabs/hosts/navigation/hostTree"; -interface FolderProps { - name: string; - hosts: SSHHost[]; - color?: string; - defaultExpanded?: boolean; +interface SharedProps { + expandedPaths: Set; + onToggle: (path: string) => void; + getHostStatus: GetHostStatus; + getHostMetrics?: (hostId: number) => HostMetrics | undefined; showTags?: boolean; - getHostStatus: (hostId: number) => "online" | "offline" | "unknown"; onHostPress: (host: SSHHost) => void; } -export default function Folder({ - name, - hosts, - color: folderColor, - defaultExpanded = true, - showTags = true, - getHostStatus, - onHostPress, -}: FolderProps) { - const [expanded, setExpanded] = useState(defaultExpanded); - const resolve = useThemeColor(); - const muted = resolve("muted-foreground"); +/** Mutable counter so striping is continuous across the whole flattened tree. */ +type StripeCounter = { i: number }; + +function TreeNode({ + node, + depth, + stripe, + shared, +}: { + node: HostTreeNode; + depth: number; + stripe: StripeCounter; + shared: SharedProps; +}) { + const color = useThemeColor(); + const muted = color("muted-foreground") ?? "#9ca3af"; + const accent = color("accent-brand") ?? "#f59145"; + + if (!isFolder(node)) { + const striped = stripe.i++ % 2 === 1; + return ( + + ); + } + + const isOpen = shared.expandedPaths.has(node.path); + const { total, online } = folderCounts(node, shared.getHostStatus); + const folderStriped = stripe.i++ % 2 === 1; return ( - + setExpanded((e) => !e)} - className="flex-row items-center gap-2 px-2 py-2" + onPress={() => shared.onToggle(node.path)} + className={`flex-row items-center gap-2 px-2 py-2 active:bg-muted/40 ${ + folderStriped ? "bg-muted/20" : "" + }`} > - + - {name} + {node.name} + + + {online > 0 ? ( + + {online} + + ) : null} + /{total} - {hosts.length} - {expanded ? ( - - {hosts.length === 0 ? ( + {isOpen ? ( + + {node.children.length === 0 ? ( No hosts in this folder ) : ( - hosts.map((host) => ( - ( + )) )} @@ -78,3 +113,27 @@ export default function Folder({ ); } + +/** + * Renders a list of host-tree nodes (folders nest recursively, hosts are + * leaves). Expansion state is controlled by the parent so it can be persisted. + */ +export default function HostTree({ + nodes, + ...shared +}: { nodes: HostTreeNode[] } & SharedProps) { + const stripe: StripeCounter = { i: 0 }; + return ( + + {nodes.map((node) => ( + + ))} + + ); +} diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index b0ac61f..4a9b3c2 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -1,35 +1,77 @@ import { View, Pressable } from "react-native"; -import { Pin } from "lucide-react-native"; +import { Pin, Cpu, MemoryStick } from "lucide-react-native"; import { SSHHost } from "@/types"; import { Text } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; +import type { HostStatus } from "@/app/tabs/hosts/navigation/hostTree"; + +export interface HostMetrics { + cpu: number | null; + ram: number | null; +} interface HostProps { host: SSHHost; - status: "online" | "offline" | "unknown"; + status: HostStatus; + metrics?: HostMetrics; showTags?: boolean; + /** Even rows get a subtle striped background (matches the web density). */ + striped?: boolean; onPress: (host: SSHHost) => void; } -const STATUS_COLOR: Record = { - online: "#22c55e", - offline: "#ef4444", - unknown: "#9ca3af", -}; - -/** Compact protocol/feature tag (e.g. RDP, VNC, Docker) shown beside a host. */ +/** Compact protocol/feature tag (e.g. RDP, VNC, DKR) shown beside a host. */ function FeatureChip({ label }: { label: string }) { return ( - - + + {label} ); } -export default function Host({ host, status, showTags = true, onPress }: HostProps) { +/** A thin labelled usage bar (CPU / RAM) shown on online hosts. */ +function MetricBar({ + icon, + value, + high, + mid, +}: { + icon: React.ReactNode; + value: number; + high: number; + mid: number; +}) { + const color = useThemeColor(); + const accent = color("accent-brand") ?? "#f59145"; + const barColor = + value > high ? "#f87171" : value > mid ? "#facc15" : accent; + return ( + + {icon} + + + + {Math.round(value)}% + + ); +} + +export default function Host({ + host, + status, + metrics, + showTags = true, + striped = false, + onPress, +}: HostProps) { const color = useThemeColor(); + const accent = color("accent-brand") ?? "#f59145"; + const online = status === "online"; const protocols: string[] = []; if (host.enableRdp) protocols.push("RDP"); @@ -37,58 +79,93 @@ export default function Host({ host, status, showTags = true, onPress }: HostPro if (host.enableTelnet) protocols.push("TEL"); if (host.enableDocker) protocols.push("DKR"); + const showCpu = online && metrics?.cpu != null && metrics.cpu > 0; + const showRam = online && metrics?.ram != null && metrics.ram > 0; + return ( onPress(host)} - className="flex-row items-center gap-3 px-3 py-3 bg-card border border-border active:bg-muted/40" + className={`flex-row items-stretch active:bg-muted/40 ${ + striped ? "bg-muted/20" : "bg-card" + }`} > - {/* Status dot */} + {/* Status stripe */} - {/* Name + address */} - + + {/* Name row */} + {host.name} {host.pin ? ( - + ) : null} {protocols.map((p) => ( ))} - + + {/* Address */} + {host.username ? `${host.username}@` : ""} {host.ip} {host.port ? `:${host.port}` : ""} + {/* Live metrics */} + {showCpu || showRam ? ( + + {showCpu ? ( + } + value={metrics!.cpu!} + high={80} + mid={50} + /> + ) : null} + {showRam ? ( + } + value={metrics!.ram!} + high={80} + mid={60} + /> + ) : null} + + ) : null} + + {/* Tags */} {showTags && host.tags && host.tags.length > 0 ? ( - + {host.tags.slice(0, 4).map((tag, i) => ( - + {tag} ))} {host.tags.length > 4 ? ( - - - +{host.tags.length - 4} - - + + +{host.tags.length - 4} + ) : null} ) : null} diff --git a/app/tabs/hosts/navigation/hostTree.ts b/app/tabs/hosts/navigation/hostTree.ts new file mode 100644 index 0000000..53a47e1 --- /dev/null +++ b/app/tabs/hosts/navigation/hostTree.ts @@ -0,0 +1,313 @@ +import { SSHHost, HostTreeNode } from "@/types"; + +export type HostStatus = "online" | "offline" | "unknown"; +export type GetHostStatus = (hostId: number) => HostStatus; + +export const NO_FOLDER = "No Folder"; +const FOLDER_SEP = " / "; + +export type SortKey = + | "default" + | "name-asc" + | "name-desc" + | "ip-asc" + | "ip-desc" + | "status-online" + | "status-offline" + | "pinned"; + +export type FilterState = { + status: ("online" | "offline" | "pinned")[]; + protocol: ("ssh" | "rdp" | "vnc" | "telnet")[]; + features: ("terminal" | "fileManager" | "tunnel" | "docker")[]; + tags: string[]; +}; + +export const DEFAULT_FILTERS: FilterState = { + status: [], + protocol: [], + features: [], + tags: [], +}; + +export function isFolder( + node: HostTreeNode, +): node is Extract { + return node.kind === "folder"; +} + +export function filtersActive(filters: FilterState): boolean { + return Object.values(filters).some((arr) => arr.length > 0); +} + +/** + * Build a recursive folder tree from a flat host list. Folder paths use " / " + * as the nesting delimiter (matches the web app's buildHostTree). Hosts without + * a folder are collected under a root-level "No Folder" bucket. Folder metadata + * (color) is matched by leaf folder name from `folderColors`. + */ +export function buildHostTree( + hosts: SSHHost[], + folderColors: Record = {}, +): HostTreeNode[] { + const root: HostTreeNode[] = []; + const folderMap = new Map< + string, + Extract + >(); + + const getOrCreateFolder = ( + fullPath: string, + ): Extract => { + const existing = folderMap.get(fullPath); + if (existing) return existing; + const parts = fullPath.split(FOLDER_SEP); + let siblings = root; + let accumulated = ""; + let node!: Extract; + for (const part of parts) { + accumulated = accumulated ? `${accumulated}${FOLDER_SEP}${part}` : part; + let folder = folderMap.get(accumulated); + if (!folder) { + folder = { + kind: "folder", + name: part, + path: accumulated, + color: folderColors[part], + children: [], + }; + folderMap.set(accumulated, folder); + siblings.push(folder); + } + siblings = folder.children; + node = folder; + } + return node; + }; + + // Ensure empty (host-less) folders from metadata still appear is intentionally + // skipped — the list only shows folders that contain hosts, like the web. + for (const host of hosts) { + const path = (host.folder ?? "").trim(); + if (path) { + getOrCreateFolder(path).children.push({ kind: "host", host }); + } else { + // Group folderless hosts under a single root-level "No Folder" bucket. + const folder = getOrCreateFolder(NO_FOLDER); + folder.children.push({ kind: "host", host }); + } + } + + return root; +} + +/** Recursively count total and online hosts under a folder node. */ +export function folderCounts( + node: Extract, + getStatus: GetHostStatus, +): { total: number; online: number } { + let total = 0; + let online = 0; + for (const child of node.children) { + if (isFolder(child)) { + const c = folderCounts(child, getStatus); + total += c.total; + online += c.online; + } else { + total++; + if (getStatus(child.host.id) === "online") online++; + } + } + return { total, online }; +} + +function hostMatchesQuery(host: SSHHost, query: string): boolean { + return ( + host.name.toLowerCase().includes(query) || + host.ip.toLowerCase().includes(query) || + (host.username ?? "").toLowerCase().includes(query) || + (host.tags ?? []).some((t) => t.toLowerCase().includes(query)) + ); +} + +/** True if any host under this folder matches the search query. */ +export function folderHasMatch( + node: Extract, + query: string, +): boolean { + for (const child of node.children) { + if (isFolder(child)) { + if (folderHasMatch(child, query)) return true; + } else if (hostMatchesQuery(child.host, query)) { + return true; + } + } + return false; +} + +/** + * Prune the tree to hosts matching the query. Folders with no surviving + * children are dropped. Returns a new tree (does not mutate input). + */ +export function filterTreeByQuery( + nodes: HostTreeNode[], + query: string, +): HostTreeNode[] { + if (!query) return nodes; + const out: HostTreeNode[] = []; + for (const node of nodes) { + if (isFolder(node)) { + const children = filterTreeByQuery(node.children, query); + if (children.length > 0) out.push({ ...node, children }); + } else if (hostMatchesQuery(node.host, query)) { + out.push(node); + } + } + return out; +} + +function hostPassesFilters( + host: SSHHost, + filters: FilterState, + getStatus: GetHostStatus, +): boolean { + if (filters.status.length > 0) { + const status = getStatus(host.id); + const ok = + (filters.status.includes("online") && status === "online") || + (filters.status.includes("offline") && status !== "online") || + (filters.status.includes("pinned") && !!host.pin); + if (!ok) return false; + } + if (filters.protocol.length > 0) { + // SSH-only legacy hosts have no enableSsh flag; treat them as ssh. + const isSsh = + host.enableSsh ?? + (!host.enableRdp && !host.enableVnc && !host.enableTelnet); + const ok = + (filters.protocol.includes("ssh") && isSsh) || + (filters.protocol.includes("rdp") && !!host.enableRdp) || + (filters.protocol.includes("vnc") && !!host.enableVnc) || + (filters.protocol.includes("telnet") && !!host.enableTelnet); + if (!ok) return false; + } + if (filters.features.length > 0) { + const ok = + (filters.features.includes("terminal") && host.enableTerminal !== false) || + (filters.features.includes("fileManager") && !!host.enableFileManager) || + (filters.features.includes("tunnel") && !!host.enableTunnel) || + (filters.features.includes("docker") && !!host.enableDocker); + if (!ok) return false; + } + if (filters.tags.length > 0) { + const ok = filters.tags.some((tag) => (host.tags ?? []).includes(tag)); + if (!ok) return false; + } + return true; +} + +/** Prune the tree by the active filter state, dropping empty folders. */ +export function applyFilters( + nodes: HostTreeNode[], + filters: FilterState, + getStatus: GetHostStatus, +): HostTreeNode[] { + if (!filtersActive(filters)) return nodes; + const out: HostTreeNode[] = []; + for (const node of nodes) { + if (isFolder(node)) { + const children = applyFilters(node.children, filters, getStatus); + if (children.length > 0) out.push({ ...node, children }); + } else if (hostPassesFilters(node.host, filters, getStatus)) { + out.push(node); + } + } + return out; +} + +/** + * Recursively sort the tree. Folders always sort before hosts; the "No Folder" + * bucket sorts last among folders. Hosts within a level sort by the given key. + */ +export function sortHostTree( + nodes: HostTreeNode[], + key: SortKey, + getStatus: GetHostStatus, +): HostTreeNode[] { + const sorted = [...nodes].sort((a, b) => { + const aFolder = isFolder(a); + const bFolder = isFolder(b); + if (aFolder && !bFolder) return -1; + if (!aFolder && bFolder) return 1; + if (aFolder && bFolder) { + if (a.name === NO_FOLDER) return 1; + if (b.name === NO_FOLDER) return -1; + return a.name.localeCompare(b.name); + } + if (key === "default") return 0; + const ha = (a as Extract).host; + const hb = (b as Extract).host; + switch (key) { + case "name-asc": + return ha.name.localeCompare(hb.name); + case "name-desc": + return hb.name.localeCompare(ha.name); + case "ip-asc": + return ha.ip.localeCompare(hb.ip); + case "ip-desc": + return hb.ip.localeCompare(ha.ip); + case "status-online": + return ( + (getStatus(hb.id) === "online" ? 1 : 0) - + (getStatus(ha.id) === "online" ? 1 : 0) + ); + case "status-offline": + return ( + (getStatus(ha.id) === "online" ? 1 : 0) - + (getStatus(hb.id) === "online" ? 1 : 0) + ); + case "pinned": + return (hb.pin ? 1 : 0) - (ha.pin ? 1 : 0); + default: + return 0; + } + }); + return sorted.map((node) => + isFolder(node) + ? { ...node, children: sortHostTree(node.children, key, getStatus) } + : node, + ); +} + +/** Collect every folder path in the tree (for default "expand all"). */ +export function collectFolderPaths(nodes: HostTreeNode[]): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (isFolder(node)) { + paths.push(node.path); + paths.push(...collectFolderPaths(node.children)); + } + } + return paths; +} + +/** Collect paths of all folders that contain a query match (for auto-expand). */ +export function collectMatchingFolderPaths( + nodes: HostTreeNode[], + query: string, +): string[] { + if (!query) return []; + const paths: string[] = []; + for (const node of nodes) { + if (isFolder(node) && folderHasMatch(node, query)) { + paths.push(node.path); + paths.push(...collectMatchingFolderPaths(node.children, query)); + } + } + return paths; +} + +/** Union of all tags across the host list (for the filter sheet). */ +export function collectAllTags(hosts: SSHHost[]): string[] { + return [...new Set(hosts.flatMap((h) => h.tags ?? []))].sort(); +} diff --git a/package-lock.json b/package-lock.json index 879a573..dc3c80c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "expo-clipboard": "^8.0.8", "expo-constants": "~18.0.8", "expo-dev-client": "~6.0.15", + "expo-document-picker": "~14.0.8", "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", "expo-image": "~3.0.9", @@ -7679,6 +7680,15 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", + "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.17", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.17.tgz", diff --git a/package.json b/package.json index 1096dc3..edee1ed 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "expo-clipboard": "^8.0.8", "expo-constants": "~18.0.8", "expo-dev-client": "~6.0.15", + "expo-document-picker": "~14.0.8", "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", "expo-image": "~3.0.9", diff --git a/types/index.ts b/types/index.ts index 32c1e8a..5b2d30c 100644 --- a/types/index.ts +++ b/types/index.ts @@ -134,6 +134,22 @@ export interface SSHFolder { updatedAt: string; } +/** + * A node in the host sidebar tree. Folders nest recursively — built from each + * host's `folder` string by splitting on " / " (matches the web app). Leaves + * are hosts. `path` is the full accumulated folder path (e.g. "Prod / DBs") and + * is used as a stable key for expansion state. + */ +export type HostTreeNode = + | { kind: "host"; host: SSHHost } + | { + kind: "folder"; + name: string; + path: string; + color?: string; + children: HostTreeNode[]; + }; + // ============================================================================ // CREDENTIAL TYPES // ============================================================================ From e6e5a44f5a7c65ae748ce002966d2963dcfcf745 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 31 May 2026 19:12:42 +0800 Subject: [PATCH 15/35] fix: preserve composed iOS terminal input --- app/tabs/sessions/Sessions.tsx | 36 +++++++++++++++++++++ app/tabs/settings/KeyboardCustomization.tsx | 7 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 9e9a912..f371c6c 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -817,6 +817,33 @@ export default function Sessions() { underlineColorAndroid="transparent" value={hiddenInputValue} onChangeText={(text) => { + if (Platform.OS === "ios") { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + + if (!activeRef?.current || !text) { + dictationBufferRef.current = ""; + dictationSentRef.current = ""; + setHiddenInputValue(""); + return; + } + + const alreadySent = dictationSentRef.current; + const newText = text.startsWith(alreadySent) + ? text.slice(alreadySent.length) + : text; + + dictationBufferRef.current = ""; + dictationSentRef.current = text; + setHiddenInputValue(""); + if (newText) activeRef.current.sendInput(newText); + requestAnimationFrame(() => { + dictationSentRef.current = ""; + }); + return; + } + if (text.length <= dictationSentRef.current.length) { const hasPendingBuffer = Platform.OS === "android" && @@ -980,6 +1007,15 @@ export default function Sessions() { break; default: if (key.length === 1) { + if ( + Platform.OS === "ios" && + !activeModifiers.ctrl && + !activeModifiers.alt && + !activeModifiers.shift + ) { + return; + } + if (activeModifiers.ctrl) { finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); } else if (activeModifiers.alt) { diff --git a/app/tabs/settings/KeyboardCustomization.tsx b/app/tabs/settings/KeyboardCustomization.tsx index 7ebc595..d53bafe 100644 --- a/app/tabs/settings/KeyboardCustomization.tsx +++ b/app/tabs/settings/KeyboardCustomization.tsx @@ -522,7 +522,7 @@ export default function KeyboardCustomization() { Show Hints - Display the "Customize in Settings" hint + Display the Customize in Settings hint {TABS.map((tab) => { const isActive = activeTab === tab.id; + const iconColor = + color(isActive ? "accent-brand" : "muted-foreground") || + "#71717a"; return ( setActiveTab(tab.id)} className={`flex-1 items-center py-2.5 gap-1 border-b-2 ${isActive ? "border-accent-brand" : "border-transparent"}`} > - {tab.icon(isActive ? color("accent-brand") : color("muted-foreground"))} + {tab.icon(iconColor)} Date: Sun, 31 May 2026 19:33:45 +0800 Subject: [PATCH 16/35] ci: restore mobile checks --- .github/workflows/app.yml | 2 +- .github/workflows/build-ipados.yml | 4 +- .github/workflows/ci.yml | 41 + CONTRIBUTING.md | 32 +- app/_layout.tsx | 12 +- app/authentication/AuthFlow.tsx | 142 +- app/authentication/UpdateRequired.tsx | 39 +- app/components/ConnectEmptyState.tsx | 6 +- app/components/CustomTabBar.tsx | 2 +- app/components/LockScreen.tsx | 20 +- app/components/Screen.tsx | 8 +- app/components/ui/Accordion.tsx | 4 +- app/components/ui/Badge.tsx | 2 +- app/components/ui/BottomSheet.tsx | 10 +- app/components/ui/Card.tsx | 2 +- app/components/ui/Dialog.tsx | 20 +- app/components/ui/Input.tsx | 12 +- app/components/ui/SegmentedControl.tsx | 2 +- app/components/ui/SettingRow.tsx | 6 +- app/components/ui/Switch.tsx | 12 +- app/components/ui/Text.tsx | 6 +- app/contexts/KeyboardCustomizationContext.tsx | 4 +- app/main-axios.ts | 44 +- app/tabs/hosts/CredentialForm.tsx | 72 +- app/tabs/hosts/CredentialListModal.tsx | 36 +- app/tabs/hosts/HostActionSheet.tsx | 36 +- app/tabs/hosts/HostForm.tsx | 36 +- app/tabs/hosts/Hosts.tsx | 54 +- app/tabs/hosts/QuickConnect.tsx | 13 +- app/tabs/hosts/navigation/Folder.tsx | 6 +- app/tabs/hosts/navigation/Host.tsx | 50 +- app/tabs/hosts/navigation/hostTree.ts | 3 +- app/tabs/sessions/ConnectionsPanel.tsx | 56 +- app/tabs/sessions/Sessions.tsx | 4 +- app/tabs/sessions/docker/Docker.tsx | 88 +- .../sessions/file-manager/ContextMenu.tsx | 56 +- app/tabs/sessions/file-manager/FileItem.tsx | 22 +- app/tabs/sessions/file-manager/FileList.tsx | 4 +- .../sessions/file-manager/FileManager.tsx | 34 +- .../file-manager/FileManagerHeader.tsx | 4 +- app/tabs/sessions/file-manager/FileViewer.tsx | 22 +- app/tabs/sessions/navigation/TabBar.tsx | 4 +- .../sessions/server-stats/ServerStats.tsx | 11 +- .../terminal/keyboard/BottomToolbar.tsx | 9 +- .../terminal/keyboard/KeyboardKey.tsx | 2 +- .../terminal/keyboard/SnippetsBar.tsx | 36 +- app/tabs/sessions/tunnel/TunnelManager.tsx | 9 +- app/tabs/settings/KeyboardCustomization.tsx | 121 +- app/tabs/settings/Settings.tsx | 98 +- app/tabs/settings/TerminalCustomization.tsx | 70 +- .../settings/components/DraggableKeyList.tsx | 20 +- .../settings/components/DraggableRowList.tsx | 10 +- app/tabs/settings/components/KeySelector.tsx | 43 +- .../components/UnifiedDraggableList.tsx | 14 +- app/utils/toast.ts | 3 +- babel.config.js | 13 +- constants/stats-config.ts | 17 +- constants/terminal-config.ts | 14 +- constants/terminal-themes.ts | 1290 ++++++++--------- lib/frontend-logger.ts | 742 +++++----- metro.config.js | 6 +- nativewind-env.d.ts | 2 +- plugins/withIOSNetworkSecurity.js | 9 +- plugins/withNetworkSecurityConfig.js | 58 +- tsconfig.json | 6 +- types/keyboard.ts | 8 +- 66 files changed, 1981 insertions(+), 1662 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 3b9244d..46e5914 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -37,7 +37,7 @@ jobs: uses: actions/setup-node@v4.0.2 with: node-version: ${{ matrix.node }} - cache: 'npm' + cache: "npm" - name: Setup Expo and EAS uses: expo/expo-github-action@v7 diff --git a/.github/workflows/build-ipados.yml b/.github/workflows/build-ipados.yml index 1854876..1b41ace 100644 --- a/.github/workflows/build-ipados.yml +++ b/.github/workflows/build-ipados.yml @@ -13,8 +13,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77b768c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + branches: + - main + - dev-1.4.0 + push: + branches: + - main + - dev-1.4.0 + +jobs: + check: + name: checks + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Type-check + run: npx tsc --noEmit --pretty false + + - name: Validate Expo config + run: npx expo config --type public --json > /tmp/expo-config.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48e105b..4c2cab0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,13 +9,13 @@ ## Installation 1. Clone the repository: - ```sh - git clone https://github.com/Termix-SSH/Mobile - ``` + ```sh + git clone https://github.com/Termix-SSH/Mobile + ``` 2. Install the dependencies: - ```sh - npm install - ``` + ```sh + npm install + ``` ## Running the development server @@ -32,22 +32,22 @@ This will start the Expo development server. You can use `Expo Go` or use it in 1. **Fork the repository**: Click the "Fork" button at the top right of the [repository page](https://github.com/Termix-SSH/Mobile). 2. **Create a new branch**: - ```sh - git checkout -b feature/my-new-feature - ``` + ```sh + git checkout -b feature/my-new-feature + ``` 3. **Make your changes**: Implement your feature, fix, or improvement. 4. **Commit your changes**: - ```sh - git commit -m "Feature request my new feature" - ``` + ```sh + git commit -m "Feature request my new feature" + ``` 5. **Push to your fork**: - ```sh - git push origin feature/my-feature-request - ``` + ```sh + git push origin feature/my-feature-request + ``` 6. **Open a pull request**: Go to the original repository and create a PR with a clear description. ## Support If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`. Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support -channel, however, response times may be longer. \ No newline at end of file +channel, however, response times may be longer. diff --git a/app/_layout.tsx b/app/_layout.tsx index 82cc854..a26d336 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,7 +4,11 @@ import { TerminalSessionsProvider } from "./contexts/TerminalSessionsContext"; import { TerminalCustomizationProvider } from "./contexts/TerminalCustomizationContext"; import { KeyboardProvider } from "./contexts/KeyboardContext"; import { KeyboardCustomizationProvider } from "./contexts/KeyboardCustomizationContext"; -import { ThemeProvider, useTheme, useThemeColor } from "./contexts/ThemeContext"; +import { + ThemeProvider, + useTheme, + useThemeColor, +} from "./contexts/ThemeContext"; import { AppLockProvider, useAppLock } from "./contexts/AppLockContext"; import { LockScreen } from "@/app/components/LockScreen"; import AuthFlow from "@/app/authentication/AuthFlow"; @@ -30,10 +34,10 @@ function RootLayoutContent() { if (isLoading) { return ( - + Initializing… @@ -43,7 +47,7 @@ function RootLayoutContent() { setIsLoading(false); openAuthFlow("server"); }} - className="mt-6 px-6 py-3 bg-card border border-border" + className="mt-6 border border-border bg-card px-6 py-3" > {/* Shared header */} - + {step !== "server" ? ( - + {activeHost || "Server"} ) : ( )} - + @@ -275,15 +277,18 @@ export default function AuthFlow() { contentContainerStyle={{ flexGrow: 1, justifyContent: "center" }} keyboardShouldPersistTaps="handled" > - + {/* Brand mark */} - + - + TERMIX - + {step === "server" ? "CONNECT TO YOUR SERVER" : (activeHost || "").toUpperCase()} @@ -318,7 +323,7 @@ export default function AuthFlow() { {step === "login" && !caps && ( - + Connecting… @@ -338,7 +343,9 @@ export default function AuthFlow() { color={color} firstUser={!!caps?.setupRequired} onAuthenticated={finishAuthenticated} - onBack={() => setStep(caps?.setupRequired ? "server" : "login")} + onBack={() => + setStep(caps?.setupRequired ? "server" : "login") + } /> )} @@ -374,7 +381,7 @@ function ServerStep({ }) { return ( <> - + - - Enter the address of your self-hosted Termix server, including - http:// or https://. + + Enter the address of your self-hosted Termix server, including http:// + or https://. - - Back to sign in + + + Back to sign in + ); @@ -722,10 +737,10 @@ function SignupStep({ }; return ( - + {firstUser ? ( - + This is the first account on the server and will be an administrator. ) : ( @@ -772,7 +787,11 @@ function SignupStep({ > {busy ? "Creating…" : "Create account"} - + {firstUser ? "Back" : "Already have an account? Sign in"} @@ -790,7 +809,9 @@ function ResetStep({ onDone: () => void; onBack: () => void; }) { - const [phase, setPhase] = useState<"request" | "code" | "password">("request"); + const [phase, setPhase] = useState<"request" | "code" | "password">( + "request", + ); const [username, setUsername] = useState(""); const [resetCode, setResetCode] = useState(""); const [tempToken, setTempToken] = useState(""); @@ -821,7 +842,10 @@ function ResetStep({ } setBusy(true); try { - const res = await verifyPasswordResetCode(username.trim(), resetCode.trim()); + const res = await verifyPasswordResetCode( + username.trim(), + resetCode.trim(), + ); setTempToken(res?.tempToken || ""); setPhase("password"); } catch (e: any) { @@ -849,12 +873,12 @@ function ResetStep({ }; return ( - + {phase === "request" && ( <> - + Enter your username. A reset code will be generated and printed to the server's logs. @@ -881,7 +905,7 @@ function ResetStep({ {phase === "code" && ( <> - + Enter the 6-digit reset code from the server logs. - + Choose a new password. )} - - Back to sign in + + + Back to sign in + ); @@ -1084,20 +1114,20 @@ function OidcStep({ return ( - + - + Sign in - + {url.replace(/^https?:\/\//, "")} @@ -1168,7 +1198,7 @@ function OidcStep({ )} /> ) : ( - + )} diff --git a/app/authentication/UpdateRequired.tsx b/app/authentication/UpdateRequired.tsx index f28cede..eb81856 100644 --- a/app/authentication/UpdateRequired.tsx +++ b/app/authentication/UpdateRequired.tsx @@ -53,11 +53,11 @@ export default function UpdateRequired() { if (isLoading) { return ( - + Loading version information… @@ -66,46 +66,46 @@ export default function UpdateRequired() { return ( - + - + Update Available - - + + - + New version available - - A newer version of the mobile app is available. Some features may not - work correctly until you update. + + A newer version of the mobile app is available. Some features may + not work correctly until you update. - + - Installed - + Installed + v{currentMobileAppVersion} - Latest - + Latest + v{latestRelease?.version || "Unknown"} {latestRelease?.tagName ? ( - Tag - + Tag + {latestRelease.tagName} @@ -115,10 +115,7 @@ export default function UpdateRequired() { - + diff --git a/app/components/ConnectEmptyState.tsx b/app/components/ConnectEmptyState.tsx index 5092198..4edd9d2 100644 --- a/app/components/ConnectEmptyState.tsx +++ b/app/components/ConnectEmptyState.tsx @@ -21,13 +21,13 @@ export function ConnectEmptyState({ return ( - + - + {title} - + {message} @@ -560,7 +582,13 @@ export default function CredentialForm({ ); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { return ( diff --git a/app/tabs/hosts/CredentialListModal.tsx b/app/tabs/hosts/CredentialListModal.tsx index 130667a..c5c680e 100644 --- a/app/tabs/hosts/CredentialListModal.tsx +++ b/app/tabs/hosts/CredentialListModal.tsx @@ -46,7 +46,9 @@ export default function CredentialListModal({ const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [sheetCredential, setSheetCredential] = useState(null); + const [sheetCredential, setSheetCredential] = useState( + null, + ); const [formCredential, setFormCredential] = useState(null); const [formOpen, setFormOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); @@ -57,7 +59,9 @@ export default function CredentialListModal({ else setLoading(true); try { const res = await getCredentials(); - const list: Credential[] = Array.isArray(res) ? res : (res?.credentials ?? []); + const list: Credential[] = Array.isArray(res) + ? res + : (res?.credentials ?? []); setCredentials(list); } catch (e: any) { toast.error(e?.message || "Failed to load credentials"); @@ -119,7 +123,7 @@ export default function CredentialListModal({ > {/* Header */} - + {/* Search bar */} - + {filtered.length === 0 ? ( - + {searchQuery.trim() ? "No credentials match your search" @@ -213,8 +217,12 @@ export default function CredentialListModal({ visible={sheetCredential !== null} onClose={() => setSheetCredential(null)} > - - + + {sheetCredential?.name} @@ -298,17 +306,21 @@ function CredentialRow({ return ( - + {c.authType === "key" ? ( ) : ( )} - - + + {c.name} {subtitle ? ( @@ -318,7 +330,7 @@ function CredentialRow({ ) : null} {c.usageCount > 0 ? ( - + {c.usageCount} host{c.usageCount !== 1 ? "s" : ""} ) : null} diff --git a/app/tabs/hosts/HostActionSheet.tsx b/app/tabs/hosts/HostActionSheet.tsx index e498be7..b0972d5 100644 --- a/app/tabs/hosts/HostActionSheet.tsx +++ b/app/tabs/hosts/HostActionSheet.tsx @@ -25,7 +25,9 @@ import { StatsConfig, DEFAULT_STATS_CONFIG } from "@/constants/stats-config"; function parseStatsConfig(host: SSHHost): StatsConfig { try { - return host.statsConfig ? JSON.parse(host.statsConfig) : DEFAULT_STATS_CONFIG; + return host.statsConfig + ? JSON.parse(host.statsConfig) + : DEFAULT_STATS_CONFIG; } catch { return DEFAULT_STATS_CONFIG; } @@ -88,7 +90,11 @@ export function HostActionSheet({ ? { type: "terminal" as SessionType, icon: Terminal, label: "Terminal" } : null, ssh && host.enableFileManager - ? { type: "filemanager" as SessionType, icon: FolderSearch, label: "File Manager" } + ? { + type: "filemanager" as SessionType, + icon: FolderSearch, + label: "File Manager", + } : null, ssh && host.enableDocker ? { type: "docker" as SessionType, icon: Box, label: "Docker" } @@ -102,7 +108,11 @@ export function HostActionSheet({ metricsEnabled ? { type: "stats" as SessionType, icon: Server, label: "Server Stats" } : null, - ].filter(Boolean) as { type: SessionType; icon: typeof Terminal; label: string }[]; + ].filter(Boolean) as { + type: SessionType; + icon: typeof Terminal; + label: string; + }[]; // Separate protocol actions (RDP / VNC / Telnet), each with its own icon — // matches the web rather than lumping them into one "Remote Desktop" row. @@ -113,18 +123,26 @@ export function HostActionSheet({ ].filter(Boolean) as { icon: typeof Monitor; label: string }[]; const dotColor = - status === "online" ? "#22c55e" : status === "offline" ? "#ef4444" : "#9ca3af"; + status === "online" + ? "#22c55e" + : status === "offline" + ? "#ef4444" + : "#9ca3af"; return ( {/* Header */} - + - - + + {host.name} @@ -136,7 +154,7 @@ export function HostActionSheet({ {status === "online" && metrics && (metrics.cpu != null || metrics.ram != null) ? ( - + {metrics.cpu != null ? ( CPU {Math.round(metrics.cpu)}% diff --git a/app/tabs/hosts/HostForm.tsx b/app/tabs/hosts/HostForm.tsx index 6054d8a..0812d28 100644 --- a/app/tabs/hosts/HostForm.tsx +++ b/app/tabs/hosts/HostForm.tsx @@ -225,7 +225,12 @@ export default function HostForm({ toast.error("Host address is required"); return; } - if (!form.enableSsh && !form.enableRdp && !form.enableVnc && !form.enableTelnet) { + if ( + !form.enableSsh && + !form.enableRdp && + !form.enableVnc && + !form.enableTelnet + ) { toast.error("Enable at least one protocol"); return; } @@ -300,7 +305,7 @@ export default function HostForm({ > {/* Header */} - + {tabs.map((tab) => { const active = tab.id === activeTab; @@ -337,9 +346,9 @@ export default function HostForm({ setActiveTab(tab.id)} - className={`px-3.5 py-1.5 border ${ + className={`border px-3.5 py-1.5 ${ active - ? "bg-accent-brand/10 border-accent-brand/40" + ? "border-accent-brand/40 bg-accent-brand/10" : "border-border active:bg-muted/40" }`} > @@ -517,9 +526,7 @@ export default function HostForm({ variant="outline" size="sm" onPress={pickKeyFile} - icon={ - - } + icon={} > Choose file @@ -548,8 +555,9 @@ export default function HostForm({ {form.authType === "credential" ? ( {credentials.length === 0 ? ( - - No saved credentials. Tap the key icon in Hosts to add one. + + No saved credentials. Tap the key icon in Hosts to add + one. ) : ( @@ -559,7 +567,7 @@ export default function HostForm({ set("credentialId", c.id)} - className={`px-3 py-2.5 border ${selected ? "border-accent-brand/40 bg-accent-brand/10" : "border-border bg-card"}`} + className={`border px-3 py-2.5 ${selected ? "border-accent-brand/40 bg-accent-brand/10" : "border-border bg-card"}`} > set("rdpPassword", v)} secureTextEntry - placeholder={isEdit ? "•••••• (unchanged if blank)" : "Password"} + placeholder={ + isEdit ? "•••••• (unchanged if blank)" : "Password" + } autoCapitalize="none" /> @@ -792,7 +802,7 @@ function Field({ function AdvancedNote() { return ( - + Advanced display, audio, and clipboard settings are available in the Termix web app. diff --git a/app/tabs/hosts/Hosts.tsx b/app/tabs/hosts/Hosts.tsx index 37427cd..f1e1b91 100644 --- a/app/tabs/hosts/Hosts.tsx +++ b/app/tabs/hosts/Hosts.tsx @@ -169,18 +169,14 @@ export default function Hosts() { }, []); const getHostStatus = useCallback( - (hostId: number): HostStatus => - serverStatuses[hostId]?.status ?? "unknown", + (hostId: number): HostStatus => serverStatuses[hostId]?.status ?? "unknown", [serverStatuses], ); // Fetch CPU/RAM for online hosts. Never throws; failures are ignored so the // list always renders. const fetchMetrics = useCallback( - async ( - hostList: SSHHost[], - statuses: Record, - ) => { + async (hostList: SSHHost[], statuses: Record) => { const onlineIds = hostList .filter((h) => statuses[h.id]?.status === "online") .map((h) => h.id); @@ -317,10 +313,9 @@ export default function Hosts() { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); - AsyncStorage.setItem( - STORAGE_EXPANDED, - JSON.stringify([...next]), - ).catch(() => {}); + AsyncStorage.setItem(STORAGE_EXPANDED, JSON.stringify([...next])).catch( + () => {}, + ); return next; }); }, []); @@ -339,10 +334,9 @@ export default function Hosts() { ? arr.filter((v) => v !== value) : [...arr, value]; const updated = { ...prev, [group]: nextArr } as FilterState; - AsyncStorage.setItem( - STORAGE_FILTER, - JSON.stringify(updated), - ).catch(() => {}); + AsyncStorage.setItem(STORAGE_FILTER, JSON.stringify(updated)).catch( + () => {}, + ); return updated; }); }, @@ -351,10 +345,9 @@ export default function Hosts() { const clearFilters = useCallback(() => { setFilterState(DEFAULT_FILTERS); - AsyncStorage.setItem( - STORAGE_FILTER, - JSON.stringify(DEFAULT_FILTERS), - ).catch(() => {}); + AsyncStorage.setItem(STORAGE_FILTER, JSON.stringify(DEFAULT_FILTERS)).catch( + () => {}, + ); }, []); const getHostMetrics = useCallback( @@ -497,14 +490,16 @@ export default function Hosts() { } /> } > - + - + Loading hosts… @@ -544,8 +539,8 @@ export default function Hosts() { } > {isEmpty ? ( - - + + {q || isFilterActive ? "No hosts match your search" : "No hosts yet"} @@ -623,7 +618,7 @@ export default function Hosts() { ) : null} {FILTER_GROUPS.map((grp) => ( - + toggleFilter(grp.group, opt.value)} - className="flex-row items-center gap-3 px-4 py-3 border-b border-border/60 active:bg-muted/40" + className="flex-row items-center gap-3 border-b border-border/60 px-4 py-3 active:bg-muted/40" > 0 ? ( - + toggleFilter("tags", tag)} - className="flex-row items-center gap-3 px-4 py-3 border-b border-border/60 active:bg-muted/40" + className="flex-row items-center gap-3 border-b border-border/60 px-4 py-3 active:bg-muted/40" > toggleFilter("tags", tag)} /> - + {tag} diff --git a/app/tabs/hosts/QuickConnect.tsx b/app/tabs/hosts/QuickConnect.tsx index 1636a4b..8fb4fc5 100644 --- a/app/tabs/hosts/QuickConnect.tsx +++ b/app/tabs/hosts/QuickConnect.tsx @@ -1,10 +1,5 @@ import { useState } from "react"; -import { - View, - ScrollView, - KeyboardAvoidingView, - Platform, -} from "react-native"; +import { View, ScrollView, KeyboardAvoidingView, Platform } from "react-native"; import { Zap } from "lucide-react-native"; import { SSHHost } from "@/types"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; @@ -87,11 +82,11 @@ export function QuickConnect({ behavior={Platform.OS === "ios" ? "padding" : undefined} > {/* Header */} - - + + - + Quick Connect diff --git a/app/tabs/hosts/navigation/Folder.tsx b/app/tabs/hosts/navigation/Folder.tsx index b0c06d1..e0e97f6 100644 --- a/app/tabs/hosts/navigation/Folder.tsx +++ b/app/tabs/hosts/navigation/Folder.tsx @@ -76,7 +76,7 @@ function TreeNode({ /> {node.name} @@ -92,9 +92,9 @@ function TreeNode({ {isOpen ? ( - + {node.children.length === 0 ? ( - + No hosts in this folder ) : ( diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 4a9b3c2..008d4fb 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -23,7 +23,7 @@ interface HostProps { /** Compact protocol/feature tag (e.g. RDP, VNC, DKR) shown beside a host. */ function FeatureChip({ label }: { label: string }) { return ( - + {label} @@ -45,18 +45,22 @@ function MetricBar({ }) { const color = useThemeColor(); const accent = color("accent-brand") ?? "#f59145"; - const barColor = - value > high ? "#f87171" : value > mid ? "#facc15" : accent; + const barColor = value > high ? "#f87171" : value > mid ? "#facc15" : accent; return ( {icon} - + - {Math.round(value)}% + + {Math.round(value)}% + ); } @@ -95,30 +99,35 @@ export default function Host({ className="w-[3px] shrink-0" /> - + {/* Name row */} {host.name} - {host.pin ? ( - - ) : null} + {host.pin ? : null} {protocols.map((p) => ( ))} {/* Address */} - + {host.username ? `${host.username}@` : ""} {host.ip} {host.port ? `:${host.port}` : ""} @@ -126,7 +135,7 @@ export default function Host({ {/* Live metrics */} {showCpu || showRam ? ( - + {showCpu ? ( } @@ -137,7 +146,12 @@ export default function Host({ ) : null} {showRam ? ( } + icon={ + + } value={metrics!.ram!} high={80} mid={60} @@ -148,11 +162,11 @@ export default function Host({ {/* Tags */} {showTags && host.tags && host.tags.length > 0 ? ( - + {host.tags.slice(0, 4).map((tag, i) => ( 0) { const ok = - (filters.features.includes("terminal") && host.enableTerminal !== false) || + (filters.features.includes("terminal") && + host.enableTerminal !== false) || (filters.features.includes("fileManager") && !!host.enableFileManager) || (filters.features.includes("tunnel") && !!host.enableTunnel) || (filters.features.includes("docker") && !!host.enableDocker); diff --git a/app/tabs/sessions/ConnectionsPanel.tsx b/app/tabs/sessions/ConnectionsPanel.tsx index a33b2b7..9b0243e 100644 --- a/app/tabs/sessions/ConnectionsPanel.tsx +++ b/app/tabs/sessions/ConnectionsPanel.tsx @@ -23,10 +23,7 @@ import { import { Text } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; import { SSHHost } from "@/types"; -import { - getSSHHosts, - deleteOpenTab, -} from "@/app/main-axios"; +import { getSSHHosts, deleteOpenTab } from "@/app/main-axios"; const TYPE_LABELS: Record = { terminal: "SSH", @@ -83,14 +80,14 @@ function toSessionType(tabType: string): SessionType { function SectionHeader({ label, count }: { label: string; count: number }) { return ( - + {label} - + {count} @@ -123,45 +120,48 @@ function ConnectionRow({ return ( {tabIcon(tabType, iconColor)} - + {name} - + {TYPE_LABELS[tabType] ?? tabType} - + {subLabel} - + {reconnectHint ? ( ) : null} @@ -171,7 +171,7 @@ function ConnectionRow({ onClose(); }} hitSlop={8} - className="w-6 h-6 items-center justify-center" + className="h-6 w-6 items-center justify-center" > @@ -256,14 +256,17 @@ export function ConnectionsPanel() { if (!hasAny) { return ( - - + + - + No active connections - + Connect to a host to start a session. Tabs you open on other devices will appear here too. @@ -291,7 +294,8 @@ export function ConnectionsPanel() { {sessions.map((s) => { const live = sessionByInstance.get(s.instanceId); - const isLive = s.type === "terminal" ? (live?.isConnected ?? false) : true; + const isLive = + s.type === "terminal" ? (live?.isConnected ?? false) : true; return ( 0 ? ( 0 ? "mt-2" : ""}> - + Sessions from other devices or backgrounded tabs. Tap to revive. @@ -333,7 +337,9 @@ export function ConnectionsPanel() { tabType={r.tabType} name={host?.name ?? r.label} subLabel={ - live?.isConnected ? "Live — tap to reconnect" : "Tap to reopen" + live?.isConnected + ? "Live — tap to reconnect" + : "Tap to reopen" } reconnectHint onSwitch={() => reviveBackground(r)} diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 9e9a912..dfcdc59 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -152,9 +152,7 @@ export default function Sessions() { return KEYBOARD_BAR_HEIGHT; }; - const getBottomMargin = ( - sessionType: SessionType = "terminal", - ) => { + const getBottomMargin = (sessionType: SessionType = "terminal") => { if (sessionType !== "terminal") { return SESSION_TAB_BAR_HEIGHT + insets.bottom; } diff --git a/app/tabs/sessions/docker/Docker.tsx b/app/tabs/sessions/docker/Docker.tsx index e49bbc1..61b82b6 100644 --- a/app/tabs/sessions/docker/Docker.tsx +++ b/app/tabs/sessions/docker/Docker.tsx @@ -96,7 +96,7 @@ export function Docker({ host, isVisible }: DockerProps) { {loading && containers.length === 0 ? ( - + Loading containers… @@ -115,41 +115,80 @@ export function Docker({ host, isVisible }: DockerProps) { } > {containers.length === 0 ? ( - + No containers found ) : ( containers.map((c) => { const running = /up|running/i.test(c.state || c.status || ""); return ( - + - - + + {c.name} - + {c.image} · {c.status} {busy === c.id ? ( - + ) : null} {running ? ( - } label="Stop" onPress={() => act(c, "stop")} /> + } + label="Stop" + onPress={() => act(c, "stop")} + /> ) : ( - } label="Start" onPress={() => act(c, "start")} accent /> + } + label="Start" + onPress={() => act(c, "start")} + accent + /> )} - } label="Restart" onPress={() => act(c, "restart")} /> - } label="Logs" onPress={() => showLogs(c)} /> - } label="Remove" onPress={() => act(c, "remove")} destructive /> + } + label="Restart" + onPress={() => act(c, "restart")} + /> + } + label="Logs" + onPress={() => showLogs(c)} + /> + } + label="Remove" + onPress={() => act(c, "remove")} + destructive + /> ); @@ -159,10 +198,21 @@ export function Docker({ host, isVisible }: DockerProps) { )} {/* Logs viewer */} - setLogs(null)}> - - - + setLogs(null)} + > + + + {logs?.name} logs setLogs(null)} hitSlop={8}> @@ -199,7 +249,7 @@ function DockerBtn({ return ( - + {}}> - - + + {fileName} @@ -90,103 +90,109 @@ export function ContextMenu({ {onView && fileType === "file" && ( handleAction(onView)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - View + View )} {onEdit && fileType === "file" && ( handleAction(onEdit)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Edit + Edit )} handleAction(onRename)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Rename + Rename handleAction(onCopy)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Copy + Copy handleAction(onCut)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Cut + Cut {onDownload && fileType === "file" && ( handleAction(onDownload)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Download + + Download + )} {onPermissions && ( handleAction(onPermissions)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Permissions + + Permissions + )} {onCompress && ( handleAction(onCompress)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Compress + + Compress + )} {onExtract && isArchive && ( handleAction(onExtract)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Extract + Extract )} handleAction(onDelete)} - className="flex-row items-center gap-3 p-3 rounded-md bg-card border border-border" + className="flex-row items-center gap-3 rounded-md border border-border bg-card p-3" activeOpacity={0.7} > - Delete + Delete diff --git a/app/tabs/sessions/file-manager/FileItem.tsx b/app/tabs/sessions/file-manager/FileItem.tsx index 8f26e63..15ec27f 100644 --- a/app/tabs/sessions/file-manager/FileItem.tsx +++ b/app/tabs/sessions/file-manager/FileItem.tsx @@ -55,14 +55,14 @@ export function FileItem({ {selectionMode && ( {isSelected && ( - + )} @@ -73,25 +73,27 @@ export function FileItem({ - + {name} - + {type === "directory" ? ( - Folder + Folder ) : ( <> {size !== undefined && ( - + {formatFileSize(size)} )} {modified && ( <> {size !== undefined && ( - + + • + )} - + {formatDate(modified)} diff --git a/app/tabs/sessions/file-manager/FileList.tsx b/app/tabs/sessions/file-manager/FileList.tsx index 5941ba1..3286b7a 100644 --- a/app/tabs/sessions/file-manager/FileList.tsx +++ b/app/tabs/sessions/file-manager/FileList.tsx @@ -57,7 +57,9 @@ export function FileList({ /> } > - This folder is empty + + This folder is empty + ); } diff --git a/app/tabs/sessions/file-manager/FileManager.tsx b/app/tabs/sessions/file-manager/FileManager.tsx index 10666de..189df5f 100644 --- a/app/tabs/sessions/file-manager/FileManager.tsx +++ b/app/tabs/sessions/file-manager/FileManager.tsx @@ -114,7 +114,9 @@ export const FileManager = forwardRef( content: string; }>({ visible: false, file: null, content: "" }); - const keepaliveInterval = useRef | null>(null); + const keepaliveInterval = useRef | null>( + null, + ); const connectToSSH = useCallback(async () => { try { @@ -462,9 +464,11 @@ export const FileManager = forwardRef( if (!isConnected) { return ( - + - Connecting to {host.name}... + + Connecting to {host.name}... + ( behavior={Platform.OS === "ios" ? "padding" : undefined} className="flex-1" > - + ( marginBottom: isLandscape ? 0 : insets.bottom, }} > - + Create New{" "} {createDialog.type === "folder" ? "Folder" : "File"} ( }} activeOpacity={0.7} > - + Cancel @@ -635,7 +639,7 @@ export const FileManager = forwardRef( }} activeOpacity={0.7} > - + Create @@ -655,9 +659,9 @@ export const FileManager = forwardRef( behavior={Platform.OS === "ios" ? "padding" : undefined} className="flex-1" > - + ( marginBottom: isLandscape ? 0 : insets.bottom, }} > - + Rename Item ( }} activeOpacity={0.7} > - + Cancel @@ -712,7 +716,7 @@ export const FileManager = forwardRef( }} activeOpacity={0.7} > - + Rename diff --git a/app/tabs/sessions/file-manager/FileManagerHeader.tsx b/app/tabs/sessions/file-manager/FileManagerHeader.tsx index a278710..5d188a2 100644 --- a/app/tabs/sessions/file-manager/FileManagerHeader.tsx +++ b/app/tabs/sessions/file-manager/FileManagerHeader.tsx @@ -9,9 +9,7 @@ import { MoreVertical, } from "lucide-react-native"; import { breadcrumbsFromPath, getBreadcrumbLabel } from "./utils/fileUtils"; -import { - getResponsivePadding, -} from "@/app/utils/responsive"; +import { getResponsivePadding } from "@/app/utils/responsive"; import { BORDERS, BORDER_COLORS, diff --git a/app/tabs/sessions/file-manager/FileViewer.tsx b/app/tabs/sessions/file-manager/FileViewer.tsx index cce7e79..fda19c9 100644 --- a/app/tabs/sessions/file-manager/FileViewer.tsx +++ b/app/tabs/sessions/file-manager/FileViewer.tsx @@ -131,7 +131,7 @@ export function FileViewer({ > - + {fileName} {filePath} @@ -162,7 +162,7 @@ export function FileViewer({ <> @@ -170,7 +170,7 @@ export function FileViewer({ @@ -200,14 +200,16 @@ export function FileViewer({ {readOnly && ( - - Read-only mode + + + Read-only mode + )} {session.title} diff --git a/app/tabs/sessions/server-stats/ServerStats.tsx b/app/tabs/sessions/server-stats/ServerStats.tsx index 3c032d3..737e004 100644 --- a/app/tabs/sessions/server-stats/ServerStats.tsx +++ b/app/tabs/sessions/server-stats/ServerStats.tsx @@ -16,12 +16,7 @@ import { type DimensionValue, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - Cpu, - MemoryStick, - HardDrive, - Server, -} from "lucide-react-native"; +import { Cpu, MemoryStick, HardDrive, Server } from "lucide-react-native"; import { getServerMetricsById, executeSnippet } from "../../../main-axios"; import { showToast } from "../../../utils/toast"; import type { ServerMetrics, QuickAction } from "../../../../types"; @@ -63,7 +58,9 @@ export const ServerStats = forwardRef( const [executingActions, setExecutingActions] = useState>( new Set(), ); - const refreshIntervalRef = useRef | null>(null); + const refreshIntervalRef = useRef | null>( + null, + ); const padding = getResponsivePadding(isLandscape); const columnCount = getColumnCount(width, isLandscape, 350); diff --git a/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx b/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx index e92d02b..ef69031 100644 --- a/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx +++ b/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx @@ -4,10 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { TerminalHandle } from "../Terminal"; import CustomKeyboard from "./CustomKeyboard"; import SnippetsBar from "./SnippetsBar"; -import { - BORDERS, - BORDER_COLORS, -} from "@/app/constants/designTokens"; +import { BORDERS, BORDER_COLORS } from "@/app/constants/designTokens"; type ToolbarMode = "keyboard" | "snippets"; @@ -51,7 +48,7 @@ export default function BottomToolbar({ {tabs.map((tab, index) => ( setMode(tab.id)} style={{ borderRightWidth: @@ -60,7 +57,7 @@ export default function BottomToolbar({ }} > diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx index 3c4c38f..f63e55a 100644 --- a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx +++ b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx @@ -87,7 +87,7 @@ export default function KeyboardKey({ delayLongPress={500} > {label} diff --git a/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx b/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx index 8458be2..25bdb53 100644 --- a/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx +++ b/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx @@ -120,7 +120,7 @@ export default function SnippetsBar({ {loading ? ( - + Loading snippets... @@ -137,7 +137,7 @@ export default function SnippetsBar({ {unfolderedSnippets.length > 0 && ( toggleFolder(0)} > - + Uncategorized - + ({unfolderedSnippets.length}) - + {collapsedFolders.has(0) ? "▶" : "▼"} @@ -167,7 +167,7 @@ export default function SnippetsBar({ unfolderedSnippets.map((snippet) => ( executeSnippet(snippet)} > {snippet.name} @@ -193,7 +193,7 @@ export default function SnippetsBar({ return ( toggleFolder(folder.id)} > - + {folder.name} - + ({folderSnippets.length}) - + {isCollapsed ? "▶" : "▼"} @@ -223,7 +223,7 @@ export default function SnippetsBar({ folderSnippets.map((snippet) => ( executeSnippet(snippet)} > {snippet.name} @@ -244,11 +244,11 @@ export default function SnippetsBar({ })} {snippets.length === 0 && ( - - + + No snippets yet - + Create snippets in the Termix web/desktop version diff --git a/app/tabs/sessions/tunnel/TunnelManager.tsx b/app/tabs/sessions/tunnel/TunnelManager.tsx index 2c1d0f6..fa66db3 100644 --- a/app/tabs/sessions/tunnel/TunnelManager.tsx +++ b/app/tabs/sessions/tunnel/TunnelManager.tsx @@ -32,10 +32,7 @@ import type { } from "../../../../types"; import { useOrientation } from "@/app/utils/orientation"; import { getResponsivePadding, getColumnCount } from "@/app/utils/responsive"; -import { - BACKGROUNDS, - RADIUS, -} from "@/app/constants/designTokens"; +import { BACKGROUNDS, RADIUS } from "@/app/constants/designTokens"; import TunnelCard from "@/app/tabs/sessions/tunnel/TunnelCard"; export type TunnelManagerHandle = { @@ -57,7 +54,9 @@ export const TunnelManager = forwardRef< const [error, setError] = useState(null); const [allHosts, setAllHosts] = useState([]); const [currentHostConfig, setCurrentHostConfig] = useState(hostConfig); - const refreshIntervalRef = useRef | null>(null); + const refreshIntervalRef = useRef | null>( + null, + ); const padding = getResponsivePadding(isLandscape); const columnCount = getColumnCount(width, isLandscape, 350); diff --git a/app/tabs/settings/KeyboardCustomization.tsx b/app/tabs/settings/KeyboardCustomization.tsx index 7ebc595..00f506e 100644 --- a/app/tabs/settings/KeyboardCustomization.tsx +++ b/app/tabs/settings/KeyboardCustomization.tsx @@ -25,11 +25,31 @@ import { renderRowItem, useRowExpansion } from "./components/DraggableRowList"; type TabType = "presets" | "topbar" | "fullKeyboard" | "settings"; type AddKeyMode = "pinned" | "topbar" | "row" | null; -const TABS: { id: TabType; label: string; icon: (c: string) => React.ReactNode }[] = [ - { id: "presets", label: "Presets", icon: (c) => }, - { id: "topbar", label: "Top Bar", icon: (c) => }, - { id: "fullKeyboard", label: "Full Keyboard", icon: (c) => }, - { id: "settings", label: "Settings", icon: (c) => }, +const TABS: { + id: TabType; + label: string; + icon: (c: string) => React.ReactNode; +}[] = [ + { + id: "presets", + label: "Presets", + icon: (c) => , + }, + { + id: "topbar", + label: "Top Bar", + icon: (c) => , + }, + { + id: "fullKeyboard", + label: "Full Keyboard", + icon: (c) => , + }, + { + id: "settings", + label: "Settings", + icon: (c) => , + }, ]; export default function KeyboardCustomization() { @@ -58,7 +78,9 @@ export default function KeyboardCustomization() { const [activeTab, setActiveTab] = useState("presets"); const [showResetConfirm, setShowResetConfirm] = useState(false); - const [resetType, setResetType] = useState<"all" | "topbar" | "fullkeyboard">("all"); + const [resetType, setResetType] = useState<"all" | "topbar" | "fullkeyboard">( + "all", + ); const [showKeySelector, setShowKeySelector] = useState(false); const [addKeyMode, setAddKeyMode] = useState(null); const [selectedRowId, setSelectedRowId] = useState(null); @@ -231,7 +253,8 @@ export default function KeyboardCustomization() { }; const getExcludedKeys = (): string[] => { - if (addKeyMode === "pinned") return config.topBar.pinnedKeys.map((k) => k.id); + if (addKeyMode === "pinned") + return config.topBar.pinnedKeys.map((k) => k.id); if (addKeyMode === "topbar") return config.topBar.keys.map((k) => k.id); if (addKeyMode === "row" && selectedRowId) { const row = config.fullKeyboard.rows.find((r) => r.id === selectedRowId); @@ -279,18 +302,21 @@ export default function KeyboardCustomization() { for (let i = 0; i <= mainHeaderIndex; i++) { const item = newData[i]; - if (item.type === "draggable-key" || item.type === "draggable-row") return false; + if (item.type === "draggable-key" || item.type === "draggable-row") + return false; } for (let i = resetButtonIndex; i < newData.length; i++) { const item = newData[i]; - if (item.type === "draggable-key" || item.type === "draggable-row") return false; + if (item.type === "draggable-key" || item.type === "draggable-row") + return false; } if (!expandedRowId) return true; const rowKeysHeaderIndex = newData.findIndex( (item) => - item.type === "row-keys-header" && (item as any).rowId === expandedRowId, + item.type === "row-keys-header" && + (item as any).rowId === expandedRowId, ); const rowCloseIndex = newData.findIndex( (item) => @@ -301,13 +327,20 @@ export default function KeyboardCustomization() { for (let i = 0; i < newData.length; i++) { const item = newData[i]; - if (item.type === "draggable-key" && (item as any).rowId === expandedRowId) { + if ( + item.type === "draggable-key" && + (item as any).rowId === expandedRowId + ) { if (i <= rowKeysHeaderIndex || i >= rowCloseIndex) return false; } } for (let i = rowKeysHeaderIndex + 1; i < rowCloseIndex; i++) { const item = newData[i]; - if (item.type === "draggable-key" && (item as any).rowId !== expandedRowId) return false; + if ( + item.type === "draggable-key" && + (item as any).rowId !== expandedRowId + ) + return false; if (item.type === "draggable-row") return false; } @@ -320,7 +353,7 @@ export default function KeyboardCustomization() { contentContainerStyle={{ padding: 16, gap: 10, paddingBottom: 40 }} > - + Choose a preset layout optimized for different use cases. @@ -330,9 +363,9 @@ export default function KeyboardCustomization() { handlePresetSelect(preset.id)} - className={`bg-card border px-3 py-3 active:opacity-80 ${isActive ? "border-accent-brand/50" : "border-border"}`} + className={`border bg-card px-3 py-3 active:opacity-80 ${isActive ? "border-accent-brand/50" : "border-border"}`} > - + {isActive ? ( - + - + + Custom Layout @@ -385,13 +418,15 @@ export default function KeyboardCustomization() { const pinnedKeys = newData .filter( - (item) => item.type === "draggable-key" && item.section === "pinned", + (item) => + item.type === "draggable-key" && item.section === "pinned", ) .map((item) => (item as any).data); const topBarKeys = newData .filter( - (item) => item.type === "draggable-key" && item.section === "topbar", + (item) => + item.type === "draggable-key" && item.section === "topbar", ) .map((item) => (item as any).data); @@ -455,18 +490,18 @@ export default function KeyboardCustomization() { contentContainerStyle={{ padding: 16, gap: 16, paddingBottom: 40 }} > {/* Key Size */} - - + + - + {(["small", "medium", "large"] as const).map((size) => { const isActive = config.settings.keySize === size; return ( updateSettings({ keySize: size })} - className={`flex-1 py-2.5 border items-center active:opacity-80 ${isActive ? "border-accent-brand/50 bg-accent-brand/10" : "border-border"}`} + className={`flex-1 items-center border py-2.5 active:opacity-80 ${isActive ? "border-accent-brand/50 bg-accent-brand/10" : "border-border"}`} > {/* Toggles */} - - + + - - + + Compact Mode - + Tighter spacing for more keys on screen @@ -501,12 +536,12 @@ export default function KeyboardCustomization() { /> - - + + Haptic Feedback - + Vibrate on key press @@ -517,12 +552,12 @@ export default function KeyboardCustomization() { - + Show Hints - - Display the "Customize in Settings" hint + + Display the Customize in Settings hint {/* Header */} - - router.back()} hitSlop={8} className="shrink-0"> + + router.back()} + hitSlop={8} + className="shrink-0" + > - + Keyboard @@ -587,13 +626,15 @@ export default function KeyboardCustomization() { {TABS.map((tab) => { const isActive = activeTab === tab.id; + const iconColor = + color(isActive ? "accent-brand" : "muted-foreground") || "#71717a"; return ( setActiveTab(tab.id)} - className={`flex-1 items-center py-2.5 gap-1 border-b-2 ${isActive ? "border-accent-brand" : "border-transparent"}`} + className={`flex-1 items-center gap-1 border-b-2 py-2.5 ${isActive ? "border-accent-brand" : "border-transparent"}`} > - {tab.icon(isActive ? color("accent-brand") : color("muted-foreground"))} + {tab.icon(iconColor)} toggle("server")} > - + } - open={open === "account"} - onToggle={() => toggle("account")} - > - - - - - {username} - - - - - - - {isAdmin ? "Administrator" : "User"} + } + open={open === "account"} + onToggle={() => toggle("account")} + > + + + + + {username} - - - - - {totpEnabled ? "Enabled" : "Disabled"} - - - {version ? ( - - - v{version} + + + + {isAdmin ? "Administrator" : "User"} + + + + + + + {totpEnabled ? "Enabled" : "Disabled"} - ) : null} - - + {version ? ( + + + + v{version} + + + ) : null} + + ) : null} {/* Appearance — headline feature */} @@ -266,7 +266,7 @@ export default function Settings() { open={open === "appearance"} onToggle={() => toggle("appearance")} > - + {/* Theme */} @@ -277,12 +277,12 @@ export default function Settings() { setTheme(th.id as ThemeId)} - className={`flex-row items-center gap-1.5 px-2 py-1.5 border ${active ? "border-accent-brand/50 bg-accent-brand/10" : "border-border"}`} + className={`flex-row items-center gap-1.5 border px-2 py-1.5 ${active ? "border-accent-brand/50 bg-accent-brand/10" : "border-border"}`} > {th.preview !== "auto" ? ( ) : null} setAccent(ac.value)} style={{ backgroundColor: ac.value }} - className={`w-8 h-8 border-2 ${active ? "border-foreground" : "border-transparent"}`} + className={`h-8 w-8 border-2 ${active ? "border-foreground" : "border-transparent"}`} /> ); })} - + } + icon={ + + } open={open === "customization"} onToggle={() => toggle("customization")} > @@ -371,13 +373,13 @@ export default function Settings() { onPress={() => router.push("/tabs/settings/TerminalCustomization" as any) } - className="flex-row items-center justify-between py-3 border-b border-border" + className="flex-row items-center justify-between border-b border-border py-3" > Terminal - + Font, theme, cursor, scrollback @@ -393,7 +395,7 @@ export default function Settings() { Keyboard - + Layout, keys, presets @@ -403,7 +405,7 @@ export default function Settings() { {/* Version footer */} - + {appVersion ? `Termix Mobile v${appVersion}` : "Termix Mobile"} diff --git a/app/tabs/settings/TerminalCustomization.tsx b/app/tabs/settings/TerminalCustomization.tsx index 2ff90c4..16550e5 100644 --- a/app/tabs/settings/TerminalCustomization.tsx +++ b/app/tabs/settings/TerminalCustomization.tsx @@ -1,25 +1,12 @@ import React, { useState } from "react"; -import { - View, - ScrollView, - Pressable, - KeyboardAvoidingView, - Platform, -} from "react-native"; +import { View, ScrollView, Pressable } from "react-native"; import { useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ArrowLeft, Type, AlignLeft } from "lucide-react-native"; import { useTerminalCustomization } from "@/app/contexts/TerminalCustomizationContext"; import { toast } from "@/app/utils/toast"; import { TERMINAL_FONTS } from "@/constants/terminal-themes"; -import { - Text, - Button, - Label, - Input, - Dialog, - FakeSwitch, -} from "@/app/components/ui"; +import { Text, Button, Input, Dialog } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; const FONT_SIZE_OPTIONS = [ @@ -92,11 +79,15 @@ export default function TerminalCustomization() { return ( {/* Header */} - - router.back()} hitSlop={8} className="shrink-0"> + + router.back()} + hitSlop={8} + className="shrink-0" + > - + Terminal @@ -106,8 +97,8 @@ export default function TerminalCustomization() { contentContainerStyle={{ padding: 16, gap: 16, paddingBottom: 40 }} > {/* Font Family */} - - + + - - + + Nerd Font support depends on the selected font being available to the WebView. @@ -127,10 +118,12 @@ export default function TerminalCustomization() { return ( handleFontFamilyChange(option.value, option.label)} + onPress={() => + handleFontFamilyChange(option.value, option.label) + } className={`flex-row items-center justify-between py-2.5 ${!isLast ? "border-b border-border" : ""}`} > - + Aa Bb Cc 123 {isActive ? ( - + {/* Font Size */} - - + + - - + + Base size for terminal text. Overrides the font size configured on the host in Termix Web UI. @@ -191,12 +184,12 @@ export default function TerminalCustomization() { > {option.label} - + {option.value}px {isActive ? ( - + Custom - - {isCustomFontSize - ? `${config.fontSize}px` - : "Enter any size"} + + {isCustomFontSize ? `${config.fontSize}px` : "Enter any size"} {isCustomFontSize ? ( - + {/* Reset */} - diff --git a/app/tabs/settings/components/DraggableKeyList.tsx b/app/tabs/settings/components/DraggableKeyList.tsx index d02d00b..7e90dc6 100644 --- a/app/tabs/settings/components/DraggableKeyList.tsx +++ b/app/tabs/settings/components/DraggableKeyList.tsx @@ -18,35 +18,39 @@ export function renderKeyItem({ drag, isActive, }: RenderKeyItemProps) { - return ; + return ( + + ); } function KeyItem({ item, onRemove, drag, isActive }: RenderKeyItemProps) { const color = useThemeColor(); return ( - + - + - + {item.label} - {item.category} + + {item.category} + {item.description ? ( {item.description} @@ -57,7 +61,7 @@ function KeyItem({ item, onRemove, drag, isActive }: RenderKeyItemProps) { diff --git a/app/tabs/settings/components/DraggableRowList.tsx b/app/tabs/settings/components/DraggableRowList.tsx index 6e9e76a..3ebb60c 100644 --- a/app/tabs/settings/components/DraggableRowList.tsx +++ b/app/tabs/settings/components/DraggableRowList.tsx @@ -34,14 +34,14 @@ function RowItem({ return ( @@ -51,11 +51,11 @@ function RowItem({ disabled={isActive} className="flex-1 flex-row items-center py-2.5 active:opacity-70" > - + {item.label} - + {item.keys.length} keys · {item.category} @@ -69,7 +69,7 @@ function RowItem({ - + onToggleVisibility(item.id)} diff --git a/app/tabs/settings/components/KeySelector.tsx b/app/tabs/settings/components/KeySelector.tsx index e255a33..a0af9a2 100644 --- a/app/tabs/settings/components/KeySelector.tsx +++ b/app/tabs/settings/components/KeySelector.tsx @@ -1,15 +1,10 @@ import React, { useState, useMemo } from "react"; -import { - View, - Modal, - ScrollView, - Pressable, -} from "react-native"; +import { View, Modal, ScrollView, Pressable } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { X } from "lucide-react-native"; import { KeyConfig, KeyCategory } from "@/types/keyboard"; import { ALL_KEYS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; -import { Text, Button, Input, Label } from "@/app/components/ui"; +import { Text, Button, Input } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; interface KeySelectorProps { @@ -43,7 +38,9 @@ export default function KeySelector({ }: KeySelectorProps) { const insets = useSafeAreaInsets(); const color = useThemeColor(); - const [selectedCategory, setSelectedCategory] = useState("all"); + const [selectedCategory, setSelectedCategory] = useState( + "all", + ); const [searchQuery, setSearchQuery] = useState(""); const allKeysArray = useMemo(() => Object.values(ALL_KEYS), []); @@ -77,8 +74,8 @@ export default function KeySelector({ > {/* Header */} - - + + {title} @@ -87,7 +84,7 @@ export default function KeySelector({ {/* Search */} - + setSelectedCategory(cat.id)} - className={`px-3 py-2.5 mr-1 border-b-2 ${isActive ? "border-accent-brand" : "border-transparent"}`} + className={`mr-1 border-b-2 px-3 py-2.5 ${isActive ? "border-accent-brand" : "border-transparent"}`} > {filteredKeys.length === 0 ? ( - + - {searchQuery ? "No keys match your search" : "No keys available"} + {searchQuery + ? "No keys match your search" + : "No keys available"} ) : ( @@ -140,11 +139,11 @@ export default function KeySelector({ onSelectKey(key)} - className="bg-card border border-border flex-row items-center px-3 py-2.5 active:opacity-70" + className="flex-row items-center border border-border bg-card px-3 py-2.5 active:opacity-70" > - - - + + + {key.label} @@ -155,14 +154,18 @@ export default function KeySelector({ {key.description ? ( {key.description} ) : null} - diff --git a/app/tabs/settings/components/UnifiedDraggableList.tsx b/app/tabs/settings/components/UnifiedDraggableList.tsx index 4870130..52dfb9e 100644 --- a/app/tabs/settings/components/UnifiedDraggableList.tsx +++ b/app/tabs/settings/components/UnifiedDraggableList.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { View, Pressable } from "react-native"; +import { View } from "react-native"; import DraggableFlatList, { RenderItemParams, ScaleDecorator, @@ -71,11 +71,11 @@ export default function UnifiedDraggableList({ }: RenderItemParams) => { if (item.type === "header") { return ( - - + + {item.subtitle ? ( - + {item.subtitle} ) : null} @@ -95,7 +95,7 @@ export default function UnifiedDraggableList({ {item.renderItem( item.data, @@ -120,7 +120,7 @@ export default function UnifiedDraggableList({ if (item.type === "row-keys-header") { return ( - + Keys in this row @@ -152,7 +152,7 @@ export default function UnifiedDraggableList({ if (isRowClose) { return ( ); diff --git a/app/utils/toast.ts b/app/utils/toast.ts index be109e6..a35b52d 100644 --- a/app/utils/toast.ts +++ b/app/utils/toast.ts @@ -17,7 +17,8 @@ function withBorder(color?: string) { } export const toast = { - success: (m: string) => sonnerToast.success(m, withBorder(STATUS_BORDER.success)), + success: (m: string) => + sonnerToast.success(m, withBorder(STATUS_BORDER.success)), error: (m: string) => sonnerToast.error(m, withBorder(STATUS_BORDER.error)), warning: (m: string) => sonnerToast.warning(m, withBorder(STATUS_BORDER.warning)), diff --git a/babel.config.js b/babel.config.js index 1243f77..ce24065 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,9 +1,6 @@ module.exports = function (api) { - api.cache(true); - return { - presets: [ - "babel-preset-expo", - "nativewind/babel", - ], - }; -}; \ No newline at end of file + api.cache(true); + return { + presets: ["babel-preset-expo", "nativewind/babel"], + }; +}; diff --git a/constants/stats-config.ts b/constants/stats-config.ts index 9b978e2..48ada8f 100644 --- a/constants/stats-config.ts +++ b/constants/stats-config.ts @@ -1,16 +1,13 @@ -export type WidgetType = - | 'cpu' - | 'memory' - | 'disk' +export type WidgetType = "cpu" | "memory" | "disk"; export interface StatsConfig { - enabledWidgets: WidgetType[]; - statusCheckEnabled?: boolean; - metricsEnabled?: boolean; + enabledWidgets: WidgetType[]; + statusCheckEnabled?: boolean; + metricsEnabled?: boolean; } export const DEFAULT_STATS_CONFIG: StatsConfig = { - enabledWidgets: ['cpu', 'memory', 'disk'], - statusCheckEnabled: true, - metricsEnabled: true, + enabledWidgets: ["cpu", "memory", "disk"], + statusCheckEnabled: true, + metricsEnabled: true, }; diff --git a/constants/terminal-config.ts b/constants/terminal-config.ts index 6200d9c..1c90ee4 100644 --- a/constants/terminal-config.ts +++ b/constants/terminal-config.ts @@ -1,9 +1,9 @@ -import { TerminalConfig } from '@/types'; -import { DEFAULT_TERMINAL_CONFIG } from './terminal-themes'; +import { TerminalConfig } from "@/types"; +import { DEFAULT_TERMINAL_CONFIG } from "./terminal-themes"; export const MOBILE_DEFAULT_TERMINAL_CONFIG: Partial = { - ...DEFAULT_TERMINAL_CONFIG, - fontSize: 14, - rightClickSelectsWord: false, - minimumContrastRatio: 1, -}; \ No newline at end of file + ...DEFAULT_TERMINAL_CONFIG, + fontSize: 14, + rightClickSelectsWord: false, + minimumContrastRatio: 1, +}; diff --git a/constants/terminal-themes.ts b/constants/terminal-themes.ts index 14620fc..63f5505 100644 --- a/constants/terminal-themes.ts +++ b/constants/terminal-themes.ts @@ -1,710 +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; - }; + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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", - }, + 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', - }, + { + 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" }, + { 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" }, + { 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" }, + { 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", + 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, + 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", + 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/lib/frontend-logger.ts b/lib/frontend-logger.ts index ffb9abb..e12e915 100644 --- a/lib/frontend-logger.ts +++ b/lib/frontend-logger.ts @@ -1,380 +1,380 @@ export type LogLevel = "debug" | "info" | "warn" | "error" | "success"; export interface LogContext { - operation?: string; - userId?: string; - hostId?: number; - tunnelName?: string; - sessionId?: string; - requestId?: string; - duration?: number; - method?: string; - url?: string; - status?: number; - statusText?: string; - responseTime?: number; - retryCount?: number; - errorCode?: string; - errorMessage?: string; - - [key: string]: any; + operation?: string; + userId?: string; + hostId?: number; + tunnelName?: string; + sessionId?: string; + requestId?: string; + duration?: number; + method?: string; + url?: string; + status?: number; + statusText?: string; + responseTime?: number; + retryCount?: number; + errorCode?: string; + errorMessage?: string; + + [key: string]: any; } class FrontendLogger { - private serviceName: string; - private serviceIcon: string; - private serviceColor: string; - private isDevelopment: boolean; - - constructor(serviceName: string, serviceIcon: string, serviceColor: string) { - this.serviceName = serviceName; - this.serviceIcon = serviceIcon; - this.serviceColor = serviceColor; - this.isDevelopment = __DEV__ || process.env.NODE_ENV === "development"; - } - - private getTimeStamp(): string { - const now = new Date(); - return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, "0")}]`; - } - - private formatMessage( - level: LogLevel, - message: string, - context?: LogContext, - ): string { - const timestamp = this.getTimeStamp(); - const levelTag = this.getLevelTag(level); - const serviceTag = this.getServiceTag(); - - let contextStr = ""; - if (context && this.isDevelopment) { - const contextParts = []; - if (context.operation) contextParts.push(context.operation); - if (context.userId) contextParts.push(`user:${context.userId}`); - if (context.hostId) contextParts.push(`host:${context.hostId}`); - if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`); - if (context.sessionId) contextParts.push(`session:${context.sessionId}`); - if (context.responseTime) contextParts.push(`${context.responseTime}ms`); - if (context.status) contextParts.push(`status:${context.status}`); - if (context.errorCode) contextParts.push(`code:${context.errorCode}`); - - if (contextParts.length > 0) { - contextStr = ` (${contextParts.join(", ")})`; - } - } - - return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`; - } - - private getLevelTag(level: LogLevel): string { - const symbols = { - debug: "🔍", - info: "ℹ️", - warn: "⚠️", - error: "❌", - success: "✅", - }; - return `${symbols[level]} [${level.toUpperCase()}]`; - } - - private getServiceTag(): string { - return `${this.serviceIcon} [${this.serviceName}]`; - } - - private shouldLog(level: LogLevel): boolean { - if (level === "debug" && !this.isDevelopment) { - return false; - } - return true; - } - - private log( - level: LogLevel, - message: string, - context?: LogContext, - error?: unknown, - ): void { - if (!this.shouldLog(level)) return; - - const formattedMessage = this.formatMessage(level, message, context); - - switch (level) { - case "debug": - console.debug(formattedMessage); - break; - case "info": - console.log(formattedMessage); - break; - case "warn": - console.warn(formattedMessage); - break; - case "error": - console.error(formattedMessage); - if (error) { - console.error("Error details:", error); - } - break; - case "success": - console.log(formattedMessage); - break; + private serviceName: string; + private serviceIcon: string; + private serviceColor: string; + private isDevelopment: boolean; + + constructor(serviceName: string, serviceIcon: string, serviceColor: string) { + this.serviceName = serviceName; + this.serviceIcon = serviceIcon; + this.serviceColor = serviceColor; + this.isDevelopment = __DEV__ || process.env.NODE_ENV === "development"; + } + + private getTimeStamp(): string { + const now = new Date(); + return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, "0")}]`; + } + + private formatMessage( + level: LogLevel, + message: string, + context?: LogContext, + ): string { + const timestamp = this.getTimeStamp(); + const levelTag = this.getLevelTag(level); + const serviceTag = this.getServiceTag(); + + let contextStr = ""; + if (context && this.isDevelopment) { + const contextParts = []; + if (context.operation) contextParts.push(context.operation); + if (context.userId) contextParts.push(`user:${context.userId}`); + if (context.hostId) contextParts.push(`host:${context.hostId}`); + if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`); + if (context.sessionId) contextParts.push(`session:${context.sessionId}`); + if (context.responseTime) contextParts.push(`${context.responseTime}ms`); + if (context.status) contextParts.push(`status:${context.status}`); + if (context.errorCode) contextParts.push(`code:${context.errorCode}`); + + if (contextParts.length > 0) { + contextStr = ` (${contextParts.join(", ")})`; + } + } + + return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`; + } + + private getLevelTag(level: LogLevel): string { + const symbols = { + debug: "🔍", + info: "ℹ️", + warn: "⚠️", + error: "❌", + success: "✅", + }; + return `${symbols[level]} [${level.toUpperCase()}]`; + } + + private getServiceTag(): string { + return `${this.serviceIcon} [${this.serviceName}]`; + } + + private shouldLog(level: LogLevel): boolean { + if (level === "debug" && !this.isDevelopment) { + return false; + } + return true; + } + + private log( + level: LogLevel, + message: string, + context?: LogContext, + error?: unknown, + ): void { + if (!this.shouldLog(level)) return; + + const formattedMessage = this.formatMessage(level, message, context); + + switch (level) { + case "debug": + console.debug(formattedMessage); + break; + case "info": + console.log(formattedMessage); + break; + case "warn": + console.warn(formattedMessage); + break; + case "error": + console.error(formattedMessage); + if (error) { + console.error("Error details:", error); } - } - - debug(message: string, context?: LogContext): void { - this.log("debug", message, context); - } - - info(message: string, context?: LogContext): void { - this.log("info", message, context); - } - - warn(message: string, context?: LogContext): void { - this.log("warn", message, context); - } - - error(message: string, error?: unknown, context?: LogContext): void { - this.log("error", message, context, error); - } - - success(message: string, context?: LogContext): void { - this.log("success", message, context); - } - - api(message: string, context?: LogContext): void { - this.info(`API: ${message}`, { ...context, operation: "api" }); - } - - request(message: string, context?: LogContext): void { - this.info(`REQUEST: ${message}`, { ...context, operation: "request" }); - } - - response(message: string, context?: LogContext): void { - this.info(`RESPONSE: ${message}`, { ...context, operation: "response" }); - } - - auth(message: string, context?: LogContext): void { - this.info(`AUTH: ${message}`, { ...context, operation: "auth" }); - } - - ssh(message: string, context?: LogContext): void { - this.info(`SSH: ${message}`, { ...context, operation: "ssh" }); - } - - tunnel(message: string, context?: LogContext): void { - this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" }); - } - - file(message: string, context?: LogContext): void { - this.info(`FILE: ${message}`, { ...context, operation: "file" }); - } - - connection(message: string, context?: LogContext): void { - this.info(`CONNECTION: ${message}`, { - ...context, - operation: "connection", - }); - } - - disconnect(message: string, context?: LogContext): void { - this.info(`DISCONNECT: ${message}`, { - ...context, - operation: "disconnect", - }); - } - - retry(message: string, context?: LogContext): void { - this.warn(`RETRY: ${message}`, { ...context, operation: "retry" }); - } - - performance(message: string, context?: LogContext): void { - this.info(`PERFORMANCE: ${message}`, { - ...context, - operation: "performance", - }); - } - - security(message: string, context?: LogContext): void { - this.warn(`SECURITY: ${message}`, { ...context, operation: "security" }); - } - - requestStart(method: string, url: string, context?: LogContext): void { - const cleanUrl = this.sanitizeUrl(url); - const shortUrl = this.getShortUrl(cleanUrl); - - console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`); - this.request(`→ Starting request to ${cleanUrl}`, { - ...context, - method: method.toUpperCase(), - url: cleanUrl, - }); - } - - requestSuccess( - method: string, - url: string, - status: number, - responseTime: number, - context?: LogContext, - ): void { - const cleanUrl = this.sanitizeUrl(url); - const shortUrl = this.getShortUrl(cleanUrl); - const statusIcon = this.getStatusIcon(status); - const performanceIcon = this.getPerformanceIcon(responseTime); - - this.response( - `← ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, - { - ...context, - method: method.toUpperCase(), - url: cleanUrl, - status, - responseTime, - }, - ); - console.groupEnd(); - } - - requestError( - method: string, - url: string, - status: number, - errorMessage: string, - responseTime?: number, - context?: LogContext, - ): void { - const cleanUrl = this.sanitizeUrl(url); - const shortUrl = this.getShortUrl(cleanUrl); - const statusIcon = this.getStatusIcon(status); - - this.error(`← ${statusIcon} ${status} ${errorMessage}`, undefined, { - ...context, - method: method.toUpperCase(), - url: cleanUrl, - status, - errorMessage, - responseTime, - }); - console.groupEnd(); - } - - networkError( - method: string, - url: string, - errorMessage: string, - context?: LogContext, - ): void { - const cleanUrl = this.sanitizeUrl(url); - const shortUrl = this.getShortUrl(cleanUrl); - - this.error(`🌐 Network Error: ${errorMessage}`, undefined, { - ...context, - method: method.toUpperCase(), - url: cleanUrl, - errorMessage, - errorCode: "NETWORK_ERROR", - }); - console.groupEnd(); - } - - authError(method: string, url: string, context?: LogContext): void { - const cleanUrl = this.sanitizeUrl(url); - const shortUrl = this.getShortUrl(cleanUrl); - - this.security(`🔐 Authentication Required`, { - ...context, - method: method.toUpperCase(), - url: cleanUrl, - errorCode: "AUTH_REQUIRED", - }); - console.groupEnd(); - } - - retryAttempt( - method: string, - url: string, - attempt: number, - maxAttempts: number, - context?: LogContext, - ): void { - const cleanUrl = this.sanitizeUrl(url); - const shortUrl = this.getShortUrl(cleanUrl); - - this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, { - ...context, - method: method.toUpperCase(), - url: cleanUrl, - retryCount: attempt, - }); - } - - apiOperation(operation: string, details: string, context?: LogContext): void { - this.info(`🔧 ${operation}: ${details}`, { - ...context, - operation: "api_operation", - }); - } - - requestSummary( - method: string, - url: string, - status: number, - responseTime: number, - context?: LogContext, - ): void { - const cleanUrl = this.sanitizeUrl(url); - const shortUrl = this.getShortUrl(cleanUrl); - const statusIcon = this.getStatusIcon(status); - const performanceIcon = this.getPerformanceIcon(responseTime); - - console.log( - `%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, - "color: #666; font-style: italic; font-size: 0.9em;", - context, - ); - } - - private getShortUrl(url: string): string { - try { - const urlObj = new URL(url); - const path = urlObj.pathname; - const query = urlObj.search; - return `${urlObj.hostname}${path}${query}`; - } catch { - return url.length > 50 ? url.substring(0, 47) + "..." : url; - } - } - - private getStatusIcon(status: number): string { - if (status >= 200 && status < 300) return "✅"; - if (status >= 300 && status < 400) return "↩️"; - if (status >= 400 && status < 500) return "⚠️"; - if (status >= 500) return "❌"; - return "❓"; - } - - private getPerformanceIcon(responseTime: number): string { - if (responseTime < 100) return "⚡"; - if (responseTime < 500) return "🚀"; - if (responseTime < 1000) return "🏃"; - if (responseTime < 3000) return "🚶"; - return "🐌"; - } - - private sanitizeUrl(url: string): string { - try { - const urlObj = new URL(url); - if ( - urlObj.searchParams.has("password") || - urlObj.searchParams.has("token") - ) { - urlObj.search = ""; - } - return urlObj.toString(); - } catch { - return url; - } - } + break; + case "success": + console.log(formattedMessage); + break; + } + } + + debug(message: string, context?: LogContext): void { + this.log("debug", message, context); + } + + info(message: string, context?: LogContext): void { + this.log("info", message, context); + } + + warn(message: string, context?: LogContext): void { + this.log("warn", message, context); + } + + error(message: string, error?: unknown, context?: LogContext): void { + this.log("error", message, context, error); + } + + success(message: string, context?: LogContext): void { + this.log("success", message, context); + } + + api(message: string, context?: LogContext): void { + this.info(`API: ${message}`, { ...context, operation: "api" }); + } + + request(message: string, context?: LogContext): void { + this.info(`REQUEST: ${message}`, { ...context, operation: "request" }); + } + + response(message: string, context?: LogContext): void { + this.info(`RESPONSE: ${message}`, { ...context, operation: "response" }); + } + + auth(message: string, context?: LogContext): void { + this.info(`AUTH: ${message}`, { ...context, operation: "auth" }); + } + + ssh(message: string, context?: LogContext): void { + this.info(`SSH: ${message}`, { ...context, operation: "ssh" }); + } + + tunnel(message: string, context?: LogContext): void { + this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" }); + } + + file(message: string, context?: LogContext): void { + this.info(`FILE: ${message}`, { ...context, operation: "file" }); + } + + connection(message: string, context?: LogContext): void { + this.info(`CONNECTION: ${message}`, { + ...context, + operation: "connection", + }); + } + + disconnect(message: string, context?: LogContext): void { + this.info(`DISCONNECT: ${message}`, { + ...context, + operation: "disconnect", + }); + } + + retry(message: string, context?: LogContext): void { + this.warn(`RETRY: ${message}`, { ...context, operation: "retry" }); + } + + performance(message: string, context?: LogContext): void { + this.info(`PERFORMANCE: ${message}`, { + ...context, + operation: "performance", + }); + } + + security(message: string, context?: LogContext): void { + this.warn(`SECURITY: ${message}`, { ...context, operation: "security" }); + } + + requestStart(method: string, url: string, context?: LogContext): void { + const cleanUrl = this.sanitizeUrl(url); + const shortUrl = this.getShortUrl(cleanUrl); + + console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`); + this.request(`→ Starting request to ${cleanUrl}`, { + ...context, + method: method.toUpperCase(), + url: cleanUrl, + }); + } + + requestSuccess( + method: string, + url: string, + status: number, + responseTime: number, + context?: LogContext, + ): void { + const cleanUrl = this.sanitizeUrl(url); + const shortUrl = this.getShortUrl(cleanUrl); + const statusIcon = this.getStatusIcon(status); + const performanceIcon = this.getPerformanceIcon(responseTime); + + this.response( + `← ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, + { + ...context, + method: method.toUpperCase(), + url: cleanUrl, + status, + responseTime, + }, + ); + console.groupEnd(); + } + + requestError( + method: string, + url: string, + status: number, + errorMessage: string, + responseTime?: number, + context?: LogContext, + ): void { + const cleanUrl = this.sanitizeUrl(url); + const shortUrl = this.getShortUrl(cleanUrl); + const statusIcon = this.getStatusIcon(status); + + this.error(`← ${statusIcon} ${status} ${errorMessage}`, undefined, { + ...context, + method: method.toUpperCase(), + url: cleanUrl, + status, + errorMessage, + responseTime, + }); + console.groupEnd(); + } + + networkError( + method: string, + url: string, + errorMessage: string, + context?: LogContext, + ): void { + const cleanUrl = this.sanitizeUrl(url); + const shortUrl = this.getShortUrl(cleanUrl); + + this.error(`🌐 Network Error: ${errorMessage}`, undefined, { + ...context, + method: method.toUpperCase(), + url: cleanUrl, + errorMessage, + errorCode: "NETWORK_ERROR", + }); + console.groupEnd(); + } + + authError(method: string, url: string, context?: LogContext): void { + const cleanUrl = this.sanitizeUrl(url); + const shortUrl = this.getShortUrl(cleanUrl); + + this.security(`🔐 Authentication Required`, { + ...context, + method: method.toUpperCase(), + url: cleanUrl, + errorCode: "AUTH_REQUIRED", + }); + console.groupEnd(); + } + + retryAttempt( + method: string, + url: string, + attempt: number, + maxAttempts: number, + context?: LogContext, + ): void { + const cleanUrl = this.sanitizeUrl(url); + const shortUrl = this.getShortUrl(cleanUrl); + + this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, { + ...context, + method: method.toUpperCase(), + url: cleanUrl, + retryCount: attempt, + }); + } + + apiOperation(operation: string, details: string, context?: LogContext): void { + this.info(`🔧 ${operation}: ${details}`, { + ...context, + operation: "api_operation", + }); + } + + requestSummary( + method: string, + url: string, + status: number, + responseTime: number, + context?: LogContext, + ): void { + const cleanUrl = this.sanitizeUrl(url); + const shortUrl = this.getShortUrl(cleanUrl); + const statusIcon = this.getStatusIcon(status); + const performanceIcon = this.getPerformanceIcon(responseTime); + + console.log( + `%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, + "color: #666; font-style: italic; font-size: 0.9em;", + context, + ); + } + + private getShortUrl(url: string): string { + try { + const urlObj = new URL(url); + const path = urlObj.pathname; + const query = urlObj.search; + return `${urlObj.hostname}${path}${query}`; + } catch { + return url.length > 50 ? url.substring(0, 47) + "..." : url; + } + } + + private getStatusIcon(status: number): string { + if (status >= 200 && status < 300) return "✅"; + if (status >= 300 && status < 400) return "↩️"; + if (status >= 400 && status < 500) return "⚠️"; + if (status >= 500) return "❌"; + return "❓"; + } + + private getPerformanceIcon(responseTime: number): string { + if (responseTime < 100) return "⚡"; + if (responseTime < 500) return "🚀"; + if (responseTime < 1000) return "🏃"; + if (responseTime < 3000) return "🚶"; + return "🐌"; + } + + private sanitizeUrl(url: string): string { + try { + const urlObj = new URL(url); + if ( + urlObj.searchParams.has("password") || + urlObj.searchParams.has("token") + ) { + urlObj.search = ""; + } + return urlObj.toString(); + } catch { + return url; + } + } } export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6"); @@ -385,4 +385,4 @@ export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a"); export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e"); export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a"); -export const logger = systemLogger; \ No newline at end of file +export const logger = systemLogger; diff --git a/metro.config.js b/metro.config.js index 37cbbed..b0963fe 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,6 +1,6 @@ const { getDefaultConfig } = require("expo/metro-config"); -const { withNativeWind } = require('nativewind/metro'); +const { withNativeWind } = require("nativewind/metro"); -const config = getDefaultConfig(__dirname) +const config = getDefaultConfig(__dirname); -module.exports = withNativeWind(config, { input: './global.css' }) \ No newline at end of file +module.exports = withNativeWind(config, { input: "./global.css" }); diff --git a/nativewind-env.d.ts b/nativewind-env.d.ts index fbca8c7..a13e313 100644 --- a/nativewind-env.d.ts +++ b/nativewind-env.d.ts @@ -1 +1 @@ -/// \ No newline at end of file +/// diff --git a/plugins/withIOSNetworkSecurity.js b/plugins/withIOSNetworkSecurity.js index 7509b2b..ebeb048 100644 --- a/plugins/withIOSNetworkSecurity.js +++ b/plugins/withIOSNetworkSecurity.js @@ -1,4 +1,8 @@ -const { withInfoPlist, withDangerousMod, IOSConfig } = require("@expo/config-plugins"); +const { + withInfoPlist, + withDangerousMod, + IOSConfig, +} = require("@expo/config-plugins"); const fs = require("fs"); const withIOSNetworkSecurity = (config) => { @@ -32,8 +36,7 @@ const withIOSNetworkSecurity = (config) => { }; IOSConfig.InfoPlist.write(platformProjectRoot, infoPlist); - } catch (e) { - } + } catch (e) {} return config; }, diff --git a/plugins/withNetworkSecurityConfig.js b/plugins/withNetworkSecurityConfig.js index a489882..1266957 100644 --- a/plugins/withNetworkSecurityConfig.js +++ b/plugins/withNetworkSecurityConfig.js @@ -1,27 +1,44 @@ -const {withAndroidManifest, withDangerousMod} = require('@expo/config-plugins'); -const path = require('path'); -const fs = require('fs'); +const { + withAndroidManifest, + withDangerousMod, +} = require("@expo/config-plugins"); +const path = require("path"); +const fs = require("fs"); const withNetworkSecurityConfig = (config) => { - config = withAndroidManifest(config, async (config) => { - const mainApplication = config.modResults.manifest.application[0]; + config = withAndroidManifest(config, async (config) => { + const mainApplication = config.modResults.manifest.application[0]; - mainApplication.$['android:networkSecurityConfig'] = '@xml/network_security_config'; + mainApplication.$["android:networkSecurityConfig"] = + "@xml/network_security_config"; - return config; - }); + return config; + }); - config = withDangerousMod(config, ['android', async (config) => { - const projectRoot = config.modRequest.projectRoot; - const resXmlPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res', 'xml'); + config = withDangerousMod(config, [ + "android", + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const resXmlPath = path.join( + projectRoot, + "android", + "app", + "src", + "main", + "res", + "xml", + ); - if (!fs.existsSync(resXmlPath)) { - fs.mkdirSync(resXmlPath, {recursive: true}); - } + if (!fs.existsSync(resXmlPath)) { + fs.mkdirSync(resXmlPath, { recursive: true }); + } - const networkSecurityConfigPath = path.join(resXmlPath, 'network_security_config.xml'); + const networkSecurityConfigPath = path.join( + resXmlPath, + "network_security_config.xml", + ); - const networkSecurityConfig = ` + const networkSecurityConfig = ` @@ -42,12 +59,13 @@ const withNetworkSecurityConfig = (config) => { `; - fs.writeFileSync(networkSecurityConfigPath, networkSecurityConfig); + fs.writeFileSync(networkSecurityConfigPath, networkSecurityConfig); - return config; - },]); + return config; + }, + ]); - return config; + return config; }; module.exports = withNetworkSecurityConfig; diff --git a/tsconfig.json b/tsconfig.json index 2f35dd4..c417704 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,9 +3,7 @@ "compilerOptions": { "strict": true, "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, "include": [ @@ -15,4 +13,4 @@ "expo-env.d.ts", "nativewind-env.d.ts" ] -} \ No newline at end of file +} diff --git a/types/keyboard.ts b/types/keyboard.ts index 2a7a0a6..753bcb6 100644 --- a/types/keyboard.ts +++ b/types/keyboard.ts @@ -15,7 +15,13 @@ export type KeyWidth = "narrow" | "normal" | "wide" | "full"; export type KeySize = "small" | "medium" | "large"; -export type PresetType = "default" | "minimal" | "developer" | "sysadmin" | "compact" | "custom"; +export type PresetType = + | "default" + | "minimal" + | "developer" + | "sysadmin" + | "compact" + | "custom"; export interface KeyConfig { id: string; From e48151434ab94213b1688e0526e117fdccd2def9 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 1 Jun 2026 01:47:16 -0500 Subject: [PATCH 17/35] feat: rewrite of connection system for all types --- app/main-axios.ts | 473 ++++- .../dialogs/HostKeyVerificationDialog.tsx | 413 ++--- app/tabs/dialogs/PassphraseDialog.tsx | 93 + app/tabs/dialogs/SSHAuthDialog.tsx | 472 ++--- app/tabs/dialogs/TOTPDialog.tsx | 293 +-- app/tabs/dialogs/WarpgateDialog.tsx | 117 ++ app/tabs/dialogs/index.ts | 2 + app/tabs/hosts/navigation/Host.tsx | 5 + app/tabs/sessions/ConnectionsPanel.tsx | 9 +- app/tabs/sessions/Sessions.tsx | 96 +- app/tabs/sessions/_shared/AuthDialogs.tsx | 240 +++ app/tabs/sessions/_shared/ContextSheet.tsx | 64 + app/tabs/sessions/_shared/SessionFrame.tsx | 156 ++ app/tabs/sessions/_shared/index.ts | 11 + .../sessions/_shared/useConnectionLog.tsx | 252 +++ app/tabs/sessions/_shared/usePolling.ts | 59 + .../sessions/_shared/useSessionConnect.ts | 239 +++ app/tabs/sessions/docker/ContainerDetail.tsx | 333 ++++ app/tabs/sessions/docker/Docker.tsx | 487 +++-- app/tabs/sessions/docker/DockerConsole.tsx | 219 +++ .../sessions/file-manager/FileManager.tsx | 1583 +++++++++++------ app/tabs/sessions/file-manager/FileViewer.tsx | 196 +- .../file-manager/PermissionsDialog.tsx | 117 ++ .../sessions/file-manager/utils/fileUtils.ts | 56 +- app/tabs/sessions/navigation/TabBar.tsx | 144 +- .../sessions/remote-desktop/RemoteDesktop.tsx | 1088 ++++++++--- .../sessions/server-stats/ServerStats.tsx | 748 +++----- .../server-stats/widgets/CpuWidget.tsx | 154 +- .../server-stats/widgets/DiskWidget.tsx | 119 +- .../server-stats/widgets/FirewallWidget.tsx | 38 + .../server-stats/widgets/LoginStatsWidget.tsx | 95 + .../server-stats/widgets/MemoryWidget.tsx | 118 +- .../server-stats/widgets/NetworkWidget.tsx | 61 + .../server-stats/widgets/PortsWidget.tsx | 51 + .../server-stats/widgets/ProcessesWidget.tsx | 66 + .../server-stats/widgets/SystemWidget.tsx | 42 + .../server-stats/widgets/UptimeWidget.tsx | 35 + .../server-stats/widgets/WidgetCard.tsx | 95 + .../sessions/server-stats/widgets/index.ts | 8 + .../terminal/NativeWebSocketManager.ts | 49 + app/tabs/sessions/terminal/Terminal.tsx | 219 ++- .../terminal/keyboard/BottomToolbar.tsx | 113 +- .../terminal/keyboard/CustomKeyboard.tsx | 35 +- .../terminal/keyboard/KeyboardBar.tsx | 71 +- .../terminal/keyboard/KeyboardKey.tsx | 25 +- .../terminal/keyboard/SnippetsBar.tsx | 238 ++- app/tabs/sessions/tunnel/TunnelCard.tsx | 456 ++--- app/tabs/sessions/tunnel/TunnelManager.tsx | 628 +++---- app/utils/user.ts | 23 + constants/stats-config.ts | 47 +- constants/terminal-themes.ts | 4 +- package-lock.json | 7 +- package.json | 1 + types/index.ts | 194 +- 54 files changed, 6983 insertions(+), 3974 deletions(-) create mode 100644 app/tabs/dialogs/PassphraseDialog.tsx create mode 100644 app/tabs/dialogs/WarpgateDialog.tsx create mode 100644 app/tabs/sessions/_shared/AuthDialogs.tsx create mode 100644 app/tabs/sessions/_shared/ContextSheet.tsx create mode 100644 app/tabs/sessions/_shared/SessionFrame.tsx create mode 100644 app/tabs/sessions/_shared/index.ts create mode 100644 app/tabs/sessions/_shared/useConnectionLog.tsx create mode 100644 app/tabs/sessions/_shared/usePolling.ts create mode 100644 app/tabs/sessions/_shared/useSessionConnect.ts create mode 100644 app/tabs/sessions/docker/ContainerDetail.tsx create mode 100644 app/tabs/sessions/docker/DockerConsole.tsx create mode 100644 app/tabs/sessions/file-manager/PermissionsDialog.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/FirewallWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx create mode 100644 app/tabs/sessions/server-stats/widgets/PortsWidget.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/WidgetCard.tsx create mode 100644 app/utils/user.ts diff --git a/app/main-axios.ts b/app/main-axios.ts index b3e83b1..c187f5f 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -8,6 +8,7 @@ import type { FileManagerShortcut, ServerStatus, ServerMetrics, + LoginStatsMetrics, AuthResponse, UserInfo, UserCount, @@ -16,6 +17,10 @@ import type { ServerConfig, UptimeInfo, RecentActivityItem, + DockerContainer, + DockerContainerStats, + DockerContainerAction as DockerActionType, + SessionAuthOverrides, } from "../types/index"; import { apiLogger, @@ -217,6 +222,13 @@ function createApiInstance( } else { logger.networkError(method, fullUrl, message, context); } + } else if ( + status === 404 && + serviceName === "STATS" && + url.includes("/metrics/") + ) { + // 404 on metrics means data isn't ready yet — suppress as debug noise. + logger.debug(`Metrics not yet available: ${method} ${url}`, context); } else { logger.requestError( method, @@ -283,6 +295,23 @@ export function getCurrentServerUrl(): string | null { return configuredServerUrl; } +/** + * WebSocket URL for the Docker exec console (backend WS server on port 30009). + * Token is passed as a query param (the WS server accepts cookie / Bearer / + * `?token=`). The console speaks JSON messages: connect/input/resize/disconnect. + */ +export function getDockerConsoleWebSocketUrl(token: string): string { + const base = getRootBase(30009).replace(/\/$/, ""); + const websocketBase = base.replace(/^http/i, (scheme) => + scheme.toLowerCase() === "https" ? "wss" : "ws", + ); + const params = new URLSearchParams({ token }); + // When a real server URL is configured, nginx routes /docker/console/ → port 30009. + // In local dev (no configuredServerUrl), getRootBase already includes :30009. + const path = configuredServerUrl ? "/docker/console/" : "/"; + return `${websocketBase}${path}?${params.toString()}`; +} + export function getGuacamoleWebSocketUrl( token: string, width?: number, @@ -495,6 +524,22 @@ class ApiError extends Error { } } +/** + * Pulls a `connectionLogs` array off a connect response (or an axios error's + * response body). Session connect endpoints return these on both success and + * failure so the UI can surface a connection log. Returns [] when absent. + */ +export function extractConnectionLogs( + source: unknown, +): { type: string; stage?: string; message: string }[] { + const data = + axios.isAxiosError(source) + ? (source.response?.data as any) + : (source as any); + const logs = data?.connectionLogs; + return Array.isArray(logs) ? logs : []; +} + function handleApiError(error: unknown, operation: string): never { const context: LogContext = { operation: "error_handling", @@ -1122,6 +1167,23 @@ export async function connectSSH( } } +export async function verifySSHWarpgate( + sessionId: string, + warpgateUrl: string, + securityKey?: string, +): Promise { + try { + const response = await fileManagerApi.post("/ssh/connect-warpgate", { + sessionId, + warpgateUrl, + securityKey, + }); + return response.data; + } catch (error) { + handleApiError(error, "verify SSH Warpgate"); + } +} + export async function disconnectSSH(sessionId: string): Promise { try { const response = await fileManagerApi.post("/ssh/disconnect", { @@ -1577,6 +1639,108 @@ export async function compressSSHFiles( } } +export async function resolveSSHPath( + sessionId: string, + path: string, +): Promise<{ resolved: string }> { + try { + const response = await fileManagerApi.get("/ssh/resolvePath", { + params: { sessionId, path }, + }); + return response.data; + } catch (error) { + handleApiError(error, "resolve SSH path"); + } +} + +export async function executeSSHFile( + sessionId: string, + path: string, + hostId?: number, + userId?: string, +): Promise { + try { + const response = await fileManagerApi.post("/ssh/executeFile", { + sessionId, + path, + hostId, + userId, + }); + return response.data; + } catch (error) { + handleApiError(error, "execute SSH file"); + } +} + +/** + * Store a sudo password for a session so the backend can satisfy sudo prompts + * during file operations (mirrors the web file-manager sudo flow). + */ +export async function setSSHSudoPassword( + sessionId: string, + password: string, +): Promise { + try { + const response = await fileManagerApi.post("/sudo-password", { + sessionId, + password, + }); + return response.data; + } catch (error) { + handleApiError(error, "set sudo password"); + } +} + +// ============================================================================ +// TERMINAL COMMAND HISTORY +// ============================================================================ + +/** Per-host shell command history (deduped, newest first, max 500). */ +export async function getCommandHistory(hostId: number): Promise { + try { + const response = await authApi.get(`/terminal/command_history/${hostId}`); + const data = response.data; + if (Array.isArray(data)) { + return data + .map((row: any) => (typeof row === "string" ? row : row?.command)) + .filter((c: unknown): c is string => typeof c === "string"); + } + return []; + } catch { + return []; + } +} + +export async function saveCommandToHistory( + hostId: number, + command: string, +): Promise { + try { + await authApi.post("/terminal/command_history", { hostId, command }); + } catch { + // History is best-effort; never block the terminal on it. + } +} + +export async function deleteCommandFromHistory( + hostId: number, + command: string, +): Promise { + try { + await authApi.post("/terminal/command_history/delete", { hostId, command }); + } catch { + // Best-effort. + } +} + +export async function clearCommandHistory(hostId: number): Promise { + try { + await authApi.delete(`/terminal/command_history/${hostId}`); + } catch { + // Best-effort. + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================ @@ -1764,27 +1928,133 @@ export async function getServerStatusById(id: number): Promise { } } -export async function getServerMetricsById(id: number): Promise { +function normalizeMetrics(raw: any): ServerMetrics { + // Processes: coerce cpu/mem strings → numbers (backend sends "2.5" not 2.5) + let processes = raw.processes; + if (processes?.top) { + processes = { + ...processes, + top: processes.top.map((p: any) => ({ + ...p, + cpu: Number(p.cpu) || 0, + mem: Number(p.mem) || 0, + })), + }; + } + + // Network: map rxBytes/txBytes → rx/tx + let network = raw.network; + if (network?.interfaces) { + network = { + ...network, + interfaces: network.interfaces.map((iface: any) => ({ + ...iface, + rx: iface.rx ?? iface.rxBytes ?? null, + tx: iface.tx ?? iface.txBytes ?? null, + })), + }; + } + + // Login stats: snake_case backend key → camelCase, map to correct shape + const rawLogin = raw.login_stats ?? raw.loginStats; + const loginStats: LoginStatsMetrics | undefined = rawLogin + ? { + recentLogins: Array.isArray(rawLogin.recentLogins) ? rawLogin.recentLogins : [], + failedLogins: Array.isArray(rawLogin.failedLogins) ? rawLogin.failedLogins : [], + totalLogins: rawLogin.totalLogins ?? 0, + uniqueIPs: rawLogin.uniqueIPs ?? 0, + } + : undefined; + + return { ...raw, processes, network, loginStats } as ServerMetrics; +} + +export async function getServerMetricsById(id: number): Promise { try { const response = await statsApi.get(`/metrics/${id}`); - return response.data; + return response.data ? normalizeMetrics(response.data) : null; } 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"); - } + // Metrics not ready yet — backend is still starting collection. + return null; } handleApiError(error, "fetch server metrics"); } } +/** + * Start metrics collection for a host. Returns a viewerSessionId that must be + * passed to heartbeat/stop/unregister calls. May return requiresTOTP for 2FA hosts. + */ +export async function startMetricsPolling(id: number): Promise<{ + success?: boolean; + requiresTOTP?: boolean; + sessionId?: string; + viewerSessionId?: string; +}> { + try { + const response = await statsApi.post(`/metrics/start/${id}`); + return response.data || {}; + } catch (error) { + handleApiError(error, "start metrics polling"); + } +} + +export async function stopMetricsPolling(id: number, viewerSessionId?: string): Promise { + try { + await statsApi.post(`/metrics/stop/${id}`, viewerSessionId ? { viewerSessionId } : undefined); + } catch { + // Best-effort on teardown. + } +} + +export async function submitMetricsTOTP( + sessionId: string, + totpCode: string, +): Promise { + try { + const response = await statsApi.post("/metrics/connect-totp", { + sessionId, + totpCode, + }); + return response.data; + } catch (error) { + handleApiError(error, "submit metrics TOTP"); + } +} + +/** Register this viewer so the backend keeps polling while the screen is open. Returns a viewerSessionId. */ +export async function registerMetricsViewer(id: number): Promise<{ + success?: boolean; + viewerSessionId?: string; + skipped?: boolean; +}> { + try { + const response = await statsApi.post("/metrics/register-viewer", { hostId: id }); + return response.data || {}; + } catch { + // Best-effort. + return {}; + } +} + +export async function unregisterMetricsViewer(id: number, viewerSessionId: string): Promise { + try { + await statsApi.post("/metrics/unregister-viewer", { hostId: id, viewerSessionId }); + } catch { + // Best-effort on teardown. + } +} + +/** Heartbeat so the backend knows a viewer is still watching this host. */ +export async function sendMetricsHeartbeat(viewerSessionId: string): Promise { + try { + await statsApi.post("/metrics/heartbeat", { viewerSessionId }); + } catch { + // Best-effort. + } +} + export async function refreshServerPolling(): Promise { try { await statsApi.post("/refresh"); @@ -3309,64 +3579,191 @@ export async function getActiveSessions(): Promise { } // ============================================================================ -// DOCKER — container management over the host's docker socket -// (Guacamole helpers getGuacamoleWebSocketUrl/getGuacamoleTokenFromHost are -// defined earlier in this file — they came in with the dev-1.4.0 remote -// desktop work.) +// DOCKER — session-based container management over SSH. +// +// IMPORTANT: Docker uses the SAME session-based REST contract as the file +// manager (connect → sessionId → keepalive/status/disconnect → operations), +// served by the SSH/file-manager backend service (`fileManagerApi` base, paths +// under `/docker/...`). The previous mobile implementation called +// `sshHostApi /:hostId/docker/...` endpoints that DO NOT EXIST on the backend, +// so Docker never actually worked — this is the corrected wiring. +// +// Guacamole helpers (getGuacamoleWebSocketUrl/getGuacamoleTokenFromHost) live +// earlier in this file. // ============================================================================ -export interface DockerContainer { - id: string; - name: string; - image: string; - state: string; - status: string; +export type { + DockerContainer, + DockerContainerStats, +} from "../types/index"; + +/** + * Docker REST API base. nginx routes /docker/* → port 30007 from the server + * root (no /ssh prefix). In local dev without a configured server URL, + * getRootBase falls back to localhost:30007. + */ +function getDockerBase(): string { + return getRootBase(30007).replace(/\/$/, ""); } -export async function getDockerContainers( +function dockerApi(): AxiosInstance { + return createApiInstance(getDockerBase(), "DOCKER"); +} + +/** Establish (or reuse) an SSH session for Docker operations on a host. */ +export async function dockerConnect( + sessionId: string, hostId: number, + overrides?: SessionAuthOverrides, +): Promise { + try { + const response = await dockerApi().post("/docker/ssh/connect", { + sessionId, + hostId, + userProvidedPassword: overrides?.userProvidedPassword, + userProvidedSshKey: overrides?.userProvidedSshKey, + userProvidedKeyPassword: overrides?.userProvidedKeyPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "connect Docker session"); + } +} + +export async function dockerConnectTOTP( + sessionId: string, + totpCode: string, +): Promise { + try { + const response = await dockerApi().post("/docker/ssh/connect-totp", { + sessionId, + totpCode, + }); + return response.data; + } catch (error) { + handleApiError(error, "submit Docker TOTP"); + } +} + +export async function dockerKeepAlive(sessionId: string): Promise { + try { + await dockerApi().post("/docker/ssh/keepalive", { sessionId }); + } catch { + // Best-effort heartbeat. + } +} + +export async function dockerDisconnect(sessionId: string): Promise { + try { + await dockerApi().post("/docker/ssh/disconnect", { sessionId }); + } catch { + // Best-effort on teardown. + } +} + +export async function dockerStatus( + sessionId: string, +): Promise<{ connected: boolean }> { + try { + const response = await dockerApi().get("/docker/ssh/status", { + params: { sessionId }, + }); + return response.data || { connected: false }; + } catch { + return { connected: false }; + } +} + +/** Whether Docker is actually available on the connected host. */ +export async function dockerValidate( + sessionId: string, +): Promise<{ available: boolean; version?: string; error?: string }> { + try { + const response = await dockerApi().get(`/docker/validate/${sessionId}`); + return response.data; + } catch (error) { + handleApiError(error, "validate Docker"); + } +} + +export async function getDockerContainers( + sessionId: string, + all = true, ): Promise { try { - const response = await sshHostApi.get(`/${hostId}/docker/containers`); + const response = await dockerApi().get( + `/docker/containers/${sessionId}`, + { params: { all } }, + ); const data = response.data; return Array.isArray(data) ? data : (data?.containers ?? []); } catch (error) { - handleApiError(error, "list docker containers"); - throw error; + handleApiError(error, "list Docker containers"); + } +} + +export async function getDockerContainerDetail( + sessionId: string, + containerId: string, +): Promise { + try { + const response = await dockerApi().get( + `/docker/containers/${sessionId}/${containerId}`, + ); + return response.data; + } catch (error) { + handleApiError(error, "inspect Docker container"); + } +} + +export async function getDockerContainerStats( + sessionId: string, + containerId: string, +): Promise { + try { + const response = await dockerApi().get( + `/docker/containers/${sessionId}/${containerId}/stats`, + ); + return response.data; + } catch (error) { + handleApiError(error, "fetch Docker stats"); } } export async function dockerContainerAction( - hostId: number, + sessionId: string, containerId: string, - action: "start" | "stop" | "restart" | "pause" | "unpause" | "remove", + action: DockerActionType, ): Promise { try { if (action === "remove") { - await sshHostApi.delete(`/${hostId}/docker/containers/${containerId}`); + await dockerApi().delete( + `/docker/containers/${sessionId}/${containerId}`, + ); } else { - await sshHostApi.post( - `/${hostId}/docker/containers/${containerId}/${action}`, + await dockerApi().post( + `/docker/containers/${sessionId}/${containerId}/${action}`, ); } } catch (error) { handleApiError(error, `docker ${action}`); - throw error; } } export async function getDockerContainerLogs( - hostId: number, + sessionId: string, containerId: string, + tail = 200, ): Promise { try { - const response = await sshHostApi.get( - `/${hostId}/docker/containers/${containerId}/logs`, + const response = await dockerApi().get( + `/docker/containers/${sessionId}/${containerId}/logs`, + { params: { tail } }, ); const data = response.data; - return typeof data === "string" ? data : (data?.logs ?? ""); + if (typeof data === "string") return data; + return data?.logs ?? ""; } catch (error) { - handleApiError(error, "fetch docker logs"); - throw error; + handleApiError(error, "fetch Docker logs"); } } diff --git a/app/tabs/dialogs/HostKeyVerificationDialog.tsx b/app/tabs/dialogs/HostKeyVerificationDialog.tsx index e7a8488..0ab7a0a 100644 --- a/app/tabs/dialogs/HostKeyVerificationDialog.tsx +++ b/app/tabs/dialogs/HostKeyVerificationDialog.tsx @@ -1,24 +1,9 @@ -import React, { useState, useCallback } from "react"; -import { - View, - Text, - TouchableOpacity, - Modal, - ScrollView, - Platform, - KeyboardAvoidingView, -} from "react-native"; +import { useState, useCallback } from "react"; +import { View, Platform, Modal, Pressable, ScrollView } from "react-native"; import * as Clipboard from "expo-clipboard"; -import { Shield, AlertTriangle, Copy } 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"; +import { Shield, AlertTriangle, Copy, Check } from "lucide-react-native"; +import { Button, Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; import type { HostKeyData } from "@/app/tabs/sessions/terminal/NativeWebSocketManager"; interface HostKeyVerificationDialogProps { @@ -29,14 +14,20 @@ interface HostKeyVerificationDialogProps { onReject: () => void; } -const formatFingerprint = (fp: string) => fp.match(/.{1,2}/g)?.join(":") || fp; +const formatFingerprint = (fp: string) => fp.match(/.{1,2}/g)?.join(":") ?? fp; -const FingerprintRow: React.FC<{ +function FingerprintRow({ + label, + algorithm, + fingerprint, + keyType, +}: { label: string; algorithm: string; fingerprint: string; keyType: string; -}> = ({ label, algorithm, fingerprint, keyType }) => { +}) { + const color = useThemeColor(); const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { @@ -44,53 +35,22 @@ const FingerprintRow: React.FC<{ await Clipboard.setStringAsync(formatFingerprint(fingerprint)); setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (_) {} + } catch {} }, [fingerprint]); return ( - - + + {label} - - + + {algorithm.toUpperCase()} ({keyType}) - + {formatFingerprint(fingerprint)} - + ) : ( + + ) + } > - - - {copied ? "Copied!" : "Copy"} - - + {copied ? "Copied" : "Copy"} + ); -}; - -const HostKeyVerificationDialogComponent: React.FC< - HostKeyVerificationDialogProps -> = ({ visible, scenario, data, onAccept, onReject }) => { - const { isLandscape } = useOrientation(); - const insets = useSafeAreaInsets(); - const padding = getResponsivePadding(isLandscape); +} +export function HostKeyVerificationDialog({ + visible, + scenario, + data, + onAccept, + onReject, +}: HostKeyVerificationDialogProps) { + const color = useThemeColor(); const isChanged = scenario === "changed"; - const accentColor = isChanged ? "#ef4444" : "#22c55e"; - const accentBorder = isChanged ? "#dc2626" : "#16a34a"; - - const hostLabel = data ? `${data.hostname || data.ip}:${data.port}` : ""; + const hostLabel = data ? `${data.hostname ?? data.ip}:${data.port}` : ""; return ( - - e.stopPropagation()} > - + {/* Card with scenario-aware border */} - {isChanged ? ( - - ) : ( - - )} - - {isChanged ? "Host Key Changed!" : "Verify Host Key"} - - - - - {hostLabel} - - - - - {isChanged - ? "The host key has changed." - : "First time connecting."} - - - - {data && isChanged && data.oldFingerprint ? ( - <> - - - - ) : data ? ( - - ) : null} - - - - - Cancel - - + {isChanged ? ( + + ) : ( + + )} + + + + {isChanged ? "Host Key Changed!" : "Verify Host Key"} + + + {hostLabel} + + + - - + {/* Status banner */} + + + {isChanged + ? "The host's SSH key has changed since your last connection. This could indicate a security risk." + : "You are connecting to this host for the first time. Verify the fingerprint before trusting."} + + + + {/* Fingerprints */} + {data && isChanged && data.oldFingerprint ? ( + <> + + + + ) : data ? ( + + ) : null} + + + {/* Footer */} + + + + - - - + + + ); -}; - -export const HostKeyVerificationDialog = React.memo( - HostKeyVerificationDialogComponent, -); +} diff --git a/app/tabs/dialogs/PassphraseDialog.tsx b/app/tabs/dialogs/PassphraseDialog.tsx new file mode 100644 index 0000000..2895b72 --- /dev/null +++ b/app/tabs/dialogs/PassphraseDialog.tsx @@ -0,0 +1,93 @@ +import { useState, useEffect, useCallback } from "react"; +import { View, Platform } from "react-native"; +import { KeyRound } from "lucide-react-native"; +import { Dialog, Input, Button, Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +interface PassphraseDialogProps { + visible: boolean; + onSubmit: (passphrase: string) => void; + onCancel: () => void; + hostInfo: { + name?: string; + ip: string; + port: number; + username: string; + }; +} + +export function PassphraseDialog({ + visible, + onSubmit, + onCancel, + hostInfo, +}: PassphraseDialogProps) { + const color = useThemeColor(); + const [passphrase, setPassphrase] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!visible) { + setPassphrase(""); + setBusy(false); + } + }, [visible]); + + const handleSubmit = useCallback(() => { + if (!passphrase.trim()) return; + setBusy(true); + try { + onSubmit(passphrase); + } finally { + setBusy(false); + } + }, [passphrase, onSubmit]); + + const hostLabel = hostInfo.name + ? `${hostInfo.name} · ${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}` + : `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`; + + return ( + } + title="Passphrase Required" + description="Enter the passphrase for your SSH key." + footer={ + + + + + } + > + + {hostLabel} + + + + ); +} diff --git a/app/tabs/dialogs/SSHAuthDialog.tsx b/app/tabs/dialogs/SSHAuthDialog.tsx index 0797090..9d714a2 100644 --- a/app/tabs/dialogs/SSHAuthDialog.tsx +++ b/app/tabs/dialogs/SSHAuthDialog.tsx @@ -1,29 +1,8 @@ -import React, { - useState, - useEffect, - useCallback, - useMemo, - useRef, -} from "react"; -import { - View, - Text, - TextInput, - TouchableOpacity, - Modal, - ScrollView, - Platform, - KeyboardAvoidingView, -} 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"; +import { useState, useEffect, useCallback } from "react"; +import { View, Platform } from "react-native"; +import { Lock } from "lucide-react-native"; +import { Dialog, Input, Button, Text, SegmentedControl } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface SSHAuthDialogProps { visible: boolean; @@ -42,22 +21,18 @@ interface SSHAuthDialogProps { reason: "no_keyboard" | "auth_failed" | "timeout"; } -const SSHAuthDialogComponent: React.FC = ({ +export function SSHAuthDialog({ visible, onSubmit, onCancel, hostInfo, reason, -}) => { +}: SSHAuthDialogProps) { + const color = useThemeColor(); 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); useEffect(() => { if (!visible) { @@ -68,349 +43,116 @@ const SSHAuthDialogComponent: React.FC = ({ } }, [visible]); - 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."; - default: - return "Please provide your credentials to connect."; - } - }, [reason]); - const handleSubmit = useCallback(() => { if (authMethod === "password" && password.trim()) { onSubmit({ password }); - setPassword(""); } else if (authMethod === "key" && sshKey.trim()) { - onSubmit({ - sshKey, - keyPassword: keyPassword.trim() || undefined, - }); - setSshKey(""); - setKeyPassword(""); + onSubmit({ sshKey, keyPassword: keyPassword.trim() || undefined }); } }, [authMethod, password, sshKey, keyPassword, onSubmit]); - const handleCancel = useCallback(() => { - setPassword(""); - setSshKey(""); - setKeyPassword(""); - onCancel(); - }, [onCancel]); - - const handleSetAuthMethod = useCallback((method: "password" | "key") => { - setAuthMethod(method); - }, []); - - const isValid = useMemo( - () => - authMethod === "password" - ? password.trim().length > 0 - : sshKey.trim().length > 0, - [authMethod, password, sshKey], - ); + const isValid = + authMethod === "password" ? !!password.trim() : !!sshKey.trim(); + + const hostLabel = hostInfo.name + ? `${hostInfo.name} · ${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}` + : `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`; + + const reasonText = + reason === "no_keyboard" + ? "Keyboard-interactive auth is not available. Enter credentials directly." + : reason === "auth_failed" + ? "Authentication failed. Please re-enter your credentials." + : "Connection timed out. Please try again with your credentials."; + + const bannerBg = + reason === "auth_failed" || reason === "timeout" + ? "bg-destructive/10 border-destructive/40" + : "bg-yellow-500/10 border-yellow-500/40"; + const bannerText = + reason === "auth_failed" || reason === "timeout" + ? "text-destructive" + : "text-yellow-400"; return ( - } + title="SSH Authentication Required" + description={hostLabel} + footer={ + + + + + } > - - - + {reasonText} + + + + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "SSH Key" }, + ]} + value={authMethod} + onChange={setAuthMethod} + className="mb-3" + /> + + {authMethod === "password" ? ( + + ) : ( + + - - SSH Authentication Required - - - - - {getReasonMessage()} - - - - - handleSetAuthMethod("password")} - style={{ - flex: 1, - paddingVertical: 12, - backgroundColor: - authMethod === "password" ? "#16a34a" : "#1a1a1a", - borderWidth: BORDERS.STANDARD, - borderColor: - authMethod === "password" - ? "#16a34a" - : BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} - > - - Password - - - handleSetAuthMethod("key")} - style={{ - flex: 1, - paddingVertical: 12, - backgroundColor: authMethod === "key" ? "#16a34a" : "#1a1a1a", - borderWidth: BORDERS.STANDARD, - borderColor: - authMethod === "key" ? "#16a34a" : BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} - > - - SSH Key - - - - - {authMethod === "password" && ( - - - Password - - - - )} - - {authMethod === "key" && ( - <> - - - Private SSH Key - - - - - - Key Password (optional) - - - - - )} - - - - - Cancel - - - - - Connect - - - - - - - + /> + + + )} + ); -}; - -export const SSHAuthDialog = React.memo(SSHAuthDialogComponent); +} diff --git a/app/tabs/dialogs/TOTPDialog.tsx b/app/tabs/dialogs/TOTPDialog.tsx index e5217a3..278ab51 100644 --- a/app/tabs/dialogs/TOTPDialog.tsx +++ b/app/tabs/dialogs/TOTPDialog.tsx @@ -1,31 +1,9 @@ -import React, { - useState, - useEffect, - useCallback, - useMemo, - useRef, -} from "react"; -import { - View, - Text, - TextInput, - TouchableOpacity, - Modal, - ScrollView, - Platform, - KeyboardAvoidingView, -} from "react-native"; +import { useState, useEffect, useCallback } from "react"; +import { View } from "react-native"; import * as Clipboard from "expo-clipboard"; -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"; +import { ShieldCheck, Clipboard as ClipboardIcon } from "lucide-react-native"; +import { Dialog, Input, Button, Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; interface TOTPDialogProps { visible: boolean; @@ -35,222 +13,97 @@ interface TOTPDialogProps { isPasswordPrompt?: boolean; } -const TOTPDialogComponent: React.FC = ({ +export function TOTPDialog({ visible, onSubmit, onCancel, - prompt = "Two-Factor Authentication", + prompt, isPasswordPrompt = false, -}) => { +}: TOTPDialogProps) { + const color = useThemeColor(); const [code, setCode] = useState(""); - const { isLandscape } = useOrientation(); - const insets = useSafeAreaInsets(); - const padding = getResponsivePadding(isLandscape); - const inputRef = useRef(null); + const [busy, setBusy] = useState(false); useEffect(() => { if (!visible) { setCode(""); + setBusy(false); } }, [visible]); - useEffect(() => { - if (visible) { - const timer = setTimeout(() => { - inputRef.current?.focus(); - }, 300); - return () => clearTimeout(timer); - } - }, [visible]); - - const handleSubmit = useCallback(() => { - if (code.trim()) { - onSubmit(code); - setCode(""); + const handleSubmit = useCallback(async () => { + if (!code.trim()) return; + setBusy(true); + try { + onSubmit(code.trim()); + } finally { + setBusy(false); } }, [code, onSubmit]); - const handleCancel = useCallback(() => { - setCode(""); - onCancel(); - }, [onCancel]); - const handlePaste = useCallback(async () => { try { - const clipboardContent = await Clipboard.getStringAsync(); - if (clipboardContent) { - const pastedCode = isPasswordPrompt - ? clipboardContent - : clipboardContent.replace(/\D/g, "").slice(0, 6); - setCode(pastedCode); + const text = await Clipboard.getStringAsync(); + if (text) { + setCode(isPasswordPrompt ? text : text.replace(/\D/g, "").slice(0, 6)); } - } catch (error) { - console.error("Failed to paste from clipboard:", error); - } + } catch {} }, [isPasswordPrompt]); - const isCodeValid = useMemo(() => code.trim().length > 0, [code]); + const title = prompt || (isPasswordPrompt ? "Password Required" : "Two-Factor Authentication"); + const description = isPasswordPrompt + ? "Enter your password to continue." + : "Enter the 6-digit code from your authenticator app."; return ( - } + title={title} + description={description} + footer={ + + + + + } > - - - + setCode(isPasswordPrompt ? t : t.replace(/[^0-9]/g, "").slice(0, 6)) + } + placeholder={isPasswordPrompt ? "Password" : "000000"} + keyboardType={isPasswordPrompt ? "default" : "number-pad"} + secureTextEntry={isPasswordPrompt} + autoFocus + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + maxLength={isPasswordPrompt ? undefined : 6} + onSubmitEditing={handleSubmit} + trailing={ + + } + /> + ); -}; - -export const TOTPDialog = React.memo(TOTPDialogComponent); +} diff --git a/app/tabs/dialogs/WarpgateDialog.tsx b/app/tabs/dialogs/WarpgateDialog.tsx new file mode 100644 index 0000000..0181370 --- /dev/null +++ b/app/tabs/dialogs/WarpgateDialog.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect, useCallback } from "react"; +import { View, Linking } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { Shield, Copy, Check, ExternalLink } from "lucide-react-native"; +import { Dialog, Button, Text, Input } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { showToast } from "@/app/utils/toast"; + +interface WarpgateDialogProps { + visible: boolean; + url: string; + securityKey: string; + onContinue: () => void; + onCancel: () => void; +} + +export function WarpgateDialog({ + visible, + url, + securityKey, + onContinue, + onCancel, +}: WarpgateDialogProps) { + const color = useThemeColor(); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!visible) setCopied(false); + }, [visible]); + + const handleCopy = useCallback(async () => { + try { + await Clipboard.setStringAsync(url); + setCopied(true); + showToast("URL copied", "success"); + setTimeout(() => setCopied(false), 2000); + } catch { + showToast("Failed to copy", "error"); + } + }, [url]); + + const handleOpenBrowser = useCallback(() => { + if (url) Linking.openURL(url).catch(() => showToast("Could not open browser", "error")); + }, [url]); + + return ( + } + title="Warpgate Authentication" + description="Authenticate via the Warpgate portal, then tap Continue." + footer={ + + + + + + } + > + {/* Security key — displayed prominently */} + + + Security Key + + + + {securityKey} + + + + + {/* Auth URL */} + + + Auth URL + + + {copied ? ( + + ) : ( + + )} + + } + style={{ fontSize: 11 }} + /> + + + ); +} diff --git a/app/tabs/dialogs/index.ts b/app/tabs/dialogs/index.ts index c395ab7..520d30f 100644 --- a/app/tabs/dialogs/index.ts +++ b/app/tabs/dialogs/index.ts @@ -1,3 +1,5 @@ export { TOTPDialog } from "./TOTPDialog"; export { SSHAuthDialog } from "./SSHAuthDialog"; export { HostKeyVerificationDialog } from "./HostKeyVerificationDialog"; +export { PassphraseDialog } from "./PassphraseDialog"; +export { WarpgateDialog } from "./WarpgateDialog"; diff --git a/app/tabs/hosts/navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx index 4a9b3c2..c0964f0 100644 --- a/app/tabs/hosts/navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -73,10 +73,15 @@ export default function Host({ const accent = color("accent-brand") ?? "#f59145"; const online = status === "online"; + const sshActive = host.enableSsh !== false && host.connectionType !== "rdp" && host.connectionType !== "vnc" && host.connectionType !== "telnet"; const protocols: string[] = []; + if (sshActive) protocols.push("SSH"); if (host.enableRdp) protocols.push("RDP"); if (host.enableVnc) protocols.push("VNC"); if (host.enableTelnet) protocols.push("TEL"); + if (sshActive && host.enableTerminal) protocols.push("TERM"); + if (sshActive && host.enableFileManager) protocols.push("FILES"); + if (sshActive && host.enableTunnel) protocols.push("TUNNEL"); if (host.enableDocker) protocols.push("DKR"); const showCpu = online && metrics?.cpu != null && metrics.cpu > 0; diff --git a/app/tabs/sessions/ConnectionsPanel.tsx b/app/tabs/sessions/ConnectionsPanel.tsx index a33b2b7..ab43b95 100644 --- a/app/tabs/sessions/ConnectionsPanel.tsx +++ b/app/tabs/sessions/ConnectionsPanel.tsx @@ -187,7 +187,7 @@ function ConnectionRow({ * tab reconnects to its live backend session when one exists, enabling * cross-device tab switching. */ -export function ConnectionsPanel() { +export function ConnectionsPanel({ onClose }: { onClose?: () => void }) { const color = useThemeColor(); const { sessions, @@ -250,6 +250,7 @@ export function ConnectionsPanel() { restoredSessionId: live?.sessionId ?? record.backendSessionId ?? null, }); navigateToSessions(); + onClose?.(); }; const hasAny = sessions.length > 0 || backgroundTabs.length > 0; @@ -291,7 +292,10 @@ export function ConnectionsPanel() { {sessions.map((s) => { const live = sessionByInstance.get(s.instanceId); - const isLive = s.type === "terminal" ? (live?.isConnected ?? false) : true; + const isLive = + s.type === "terminal" + ? (live?.isConnected ?? !!s.backendSessionId) + : true; return ( { setActiveSession(s.id); navigateToSessions(); + onClose?.(); }} onClose={() => removeSession(s.id)} /> diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx index 9e9a912..47348c0 100644 --- a/app/tabs/sessions/Sessions.tsx +++ b/app/tabs/sessions/Sessions.tsx @@ -8,6 +8,7 @@ import { BackHandler, AppState, LayoutAnimation, + Pressable, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useFocusEffect } from "@react-navigation/native"; @@ -16,6 +17,7 @@ import { SessionType, useTerminalSessions, } from "@/app/contexts/TerminalSessionsContext"; +import { X } from "lucide-react-native"; import { useKeyboard } from "@/app/contexts/KeyboardContext"; import { Terminal, @@ -37,12 +39,13 @@ import { RemoteDesktop } from "@/app/tabs/sessions/remote-desktop/RemoteDesktop" import { Docker } from "@/app/tabs/sessions/docker/Docker"; import { ConnectionsPanel } from "@/app/tabs/sessions/ConnectionsPanel"; import { Screen } from "@/app/components/Screen"; +import { Text } from "@/app/components/ui"; import TabBar from "@/app/tabs/sessions/navigation/TabBar"; import BottomToolbar from "@/app/tabs/sessions/terminal/keyboard/BottomToolbar"; import KeyboardBar from "@/app/tabs/sessions/terminal/keyboard/KeyboardBar"; import { useOrientation } from "@/app/utils/orientation"; import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; -import { BACKGROUNDS } from "@/app/constants/designTokens"; +import { BACKGROUNDS, BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; import { addKeyCommandListener } from "@/modules/hardware-keyboard"; type ActiveModifiers = { @@ -59,6 +62,7 @@ export default function Sessions() { const { sessions, activeSessionId, + backgroundTabRecords, setActiveSession, removeSession, setBackendSessionId, @@ -69,6 +73,8 @@ export default function Sessions() { keyboardIntentionallyHiddenRef, setKeyboardIntentionallyHidden, } = useTerminalSessions(); + + const [showConnectionsPanel, setShowConnectionsPanel] = useState(false); const { keyboardHeight, isKeyboardVisible } = useKeyboard(); const hiddenInputRef = useRef(null); const terminalRefs = useRef>>( @@ -132,6 +138,15 @@ export default function Sessions() { (session) => session.id === activeSessionId, ); + const [isRdpKeyboardOpen, setIsRdpKeyboardOpen] = useState(false); + useEffect(() => { + const show = Keyboard.addListener("keyboardDidShow", () => { + if (activeSession?.type === "remoteDesktop") setIsRdpKeyboardOpen(true); + }); + const hide = Keyboard.addListener("keyboardDidHide", () => setIsRdpKeyboardOpen(false)); + return () => { show.remove(); hide.remove(); }; + }, [activeSession?.type]); + const getTabBarBottomPosition = () => { if (activeSession?.type !== "terminal") { return insets.bottom; @@ -294,6 +309,10 @@ export default function Sessions() { const backHandler = BackHandler.addEventListener( "hardwareBackPress", () => { + if (showConnectionsPanel) { + setShowConnectionsPanel(false); + return true; + } if (isKeyboardVisible) { setKeyboardIntentionallyHidden(true); Keyboard.dismiss(); @@ -307,7 +326,7 @@ export default function Sessions() { backHandler.remove(); }; } - }, [sessions.length, isKeyboardVisible]); + }, [sessions.length, isKeyboardVisible, showConnectionsPanel]); useEffect(() => { const subscription = Dimensions.addEventListener("change", ({ window }) => { @@ -479,6 +498,14 @@ export default function Sessions() { }, 100); }; + const closeConnectionsPanel = useCallback(() => { + setShowConnectionsPanel(false); + if (activeSession?.type === "terminal" && !isCustomKeyboardVisible) { + setKeyboardIntentionallyHidden(false); + setTimeout(() => hiddenInputRef.current?.focus(), 100); + } + }, [activeSession?.type, isCustomKeyboardVisible, setKeyboardIntentionallyHidden]); + const handleAddSession = () => { router.navigate("/hosts" as any); }; @@ -602,6 +629,7 @@ export default function Sessions() { hostConfig={{ id: parseInt(session.host.id.toString()), name: session.host.name, + statsConfig: session.host.statsConfig, quickActions: session.host.quickActions, }} isVisible={session.id === activeSessionId} @@ -658,7 +686,8 @@ export default function Sessions() { })} - {sessions.length === 0 && ( + {/* Connections panel: full screen when no sessions, overlay when sessions exist */} + {(sessions.length === 0 || showConnectionsPanel) && ( - - - + {sessions.length === 0 ? ( + + + + ) : ( + + + + + Connections + + + + + + + + + )} )} @@ -736,6 +808,7 @@ export default function Sessions() { right: 0, height: SESSION_TAB_BAR_HEIGHT, zIndex: 1004, + display: isRdpKeyboardOpen ? "none" : "flex", }} > setKeyboardIntentionallyHidden(false)} keyboardIntentionallyHiddenRef={keyboardIntentionallyHiddenRef} activeSessionType={activeSession?.type} + onShowConnections={() => { + setKeyboardIntentionallyHidden(true); + Keyboard.dismiss(); + hiddenInputRef.current?.blur(); + setShowConnectionsPanel(true); + }} + hasBackgroundSessions={backgroundTabRecords.some( + (r) => !sessions.find((s) => s.instanceId === r.id), + )} /> diff --git a/app/tabs/sessions/_shared/AuthDialogs.tsx b/app/tabs/sessions/_shared/AuthDialogs.tsx new file mode 100644 index 0000000..cd46b42 --- /dev/null +++ b/app/tabs/sessions/_shared/AuthDialogs.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { ShieldCheck, KeyRound, Lock } from "lucide-react-native"; +import { Dialog, Input, Button, Text, SegmentedControl } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { SessionAuthOverrides } from "@/types"; +import type { SessionConnectState } from "./useSessionConnect"; + +/** + * AuthDialogs — the shared interactive-auth surface for session-based connects. + * Driven entirely by the connect state machine from useSessionConnect: it shows + * the TOTP dialog in the "totp" state, the Warpgate dialog in "warpgate", and an + * interactive password/key/passphrase dialog in "auth". Wire it once per session + * type and it handles every auth method the backend can demand. + */ +export function AuthDialogs({ + state, + errorMessage, + onSubmitTotp, + onSubmitWarpgate, + onSubmitAuth, + onCancel, +}: { + state: SessionConnectState; + errorMessage?: string; + onSubmitTotp: (code: string) => Promise; + onSubmitWarpgate?: (url: string, securityKey?: string) => Promise; + onSubmitAuth: (overrides: SessionAuthOverrides) => void; + onCancel: () => void; +}) { + const color = useThemeColor(); + + // --- TOTP --- + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + useEffect(() => { + if (state !== "totp") { + setCode(""); + setBusy(false); + } + }, [state]); + + const submitTotp = async () => { + if (code.trim().length < 6) return; + setBusy(true); + try { + await onSubmitTotp(code.trim()); + } catch { + // error surfaced via errorMessage; keep dialog open + } finally { + setBusy(false); + } + }; + + // --- Warpgate --- + const [wgUrl, setWgUrl] = useState(""); + const [wgKey, setWgKey] = useState(""); + useEffect(() => { + if (state !== "warpgate") { + setWgUrl(""); + setWgKey(""); + } + }, [state]); + + // --- Interactive auth (password / key / passphrase) --- + const [authMethod, setAuthMethod] = useState<"password" | "key">("password"); + const [authPassword, setAuthPassword] = useState(""); + const [authKey, setAuthKey] = useState(""); + const [authKeyPassword, setAuthKeyPassword] = useState(""); + useEffect(() => { + if (state !== "auth") { + setAuthPassword(""); + setAuthKey(""); + setAuthKeyPassword(""); + } + }, [state]); + + const submitAuth = () => { + if (authMethod === "password") { + onSubmitAuth({ userProvidedPassword: authPassword }); + } else { + onSubmitAuth({ + userProvidedSshKey: authKey, + userProvidedKeyPassword: authKeyPassword || undefined, + }); + } + }; + + return ( + <> + {/* TOTP */} + } + title="Two-Factor Authentication" + description="Enter the 6-digit code from your authenticator app." + footer={ + + + + + } + > + setCode(t.replace(/[^0-9]/g, "").slice(0, 6))} + placeholder="000000" + keyboardType="number-pad" + autoFocus + maxLength={6} + /> + {errorMessage ? ( + {errorMessage} + ) : null} + + + {/* Warpgate */} + } + title="Warpgate Authentication" + description="Confirm the Warpgate authentication URL and security key." + footer={ + + + + + } + > + + + + + {errorMessage ? ( + {errorMessage} + ) : null} + + + {/* Interactive auth */} + } + title="Authentication Required" + description="This host needs credentials to connect." + footer={ + + + + + } + > + + options={[ + { id: "password", label: "Password" }, + { id: "key", label: "SSH Key" }, + ]} + value={authMethod} + onChange={setAuthMethod} + className="mb-3" + /> + {authMethod === "password" ? ( + + ) : ( + + + + + )} + {errorMessage ? ( + {errorMessage} + ) : null} + + + ); +} diff --git a/app/tabs/sessions/_shared/ContextSheet.tsx b/app/tabs/sessions/_shared/ContextSheet.tsx new file mode 100644 index 0000000..76f4ab4 --- /dev/null +++ b/app/tabs/sessions/_shared/ContextSheet.tsx @@ -0,0 +1,64 @@ +import { View } from "react-native"; +import { BottomSheet, SheetRow, Text } from "@/app/components/ui"; + +export interface ContextAction { + key: string; + icon: React.ReactNode; + label: string; + destructive?: boolean; + disabled?: boolean; + onPress: () => void; +} + +/** + * ContextSheet — a long-press action menu built on BottomSheet + SheetRow. + * Replaces the old absolutely-positioned context menus that could render + * off-screen. Pass a title (e.g. the file name) and a flat list of actions; + * tapping a row fires its handler and closes the sheet. + */ +export function ContextSheet({ + visible, + onClose, + title, + subtitle, + actions, +}: { + visible: boolean; + onClose: () => void; + title?: string; + subtitle?: string; + actions: (ContextAction | null | false)[]; +}) { + const items = actions.filter(Boolean) as ContextAction[]; + + return ( + + {title ? ( + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + ) : null} + {items.map((a) => ( + { + if (a.disabled) return; + onClose(); + // Defer so the sheet's close animation isn't janked by the action. + requestAnimationFrame(a.onPress); + }} + /> + ))} + + ); +} diff --git a/app/tabs/sessions/_shared/SessionFrame.tsx b/app/tabs/sessions/_shared/SessionFrame.tsx new file mode 100644 index 0000000..efaf8f3 --- /dev/null +++ b/app/tabs/sessions/_shared/SessionFrame.tsx @@ -0,0 +1,156 @@ +import { View, ActivityIndicator, ScrollView } from "react-native"; +import { AlertCircle, RotateCcw } from "lucide-react-native"; +import { Text, Button } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { ConnectionLog } from "./useConnectionLog"; +import type { ConnectionLogEntry } from "@/types"; + +export type SessionFrameStatus = + | "loading" + | "ready" + | "error" + | "empty"; + +/** + * SessionFrame — the standard chrome shared by every session type (file + * manager, docker, stats, tunnel). Renders an optional header (title/subtitle + + * trailing actions), a connection-log panel, and centralized loading / error / + * empty states so each session type doesn't reinvent them. + * + * The terminal and remote-desktop canvases are full-bleed and manage their own + * chrome, so they don't use this. + */ +export function SessionFrame({ + title, + subtitle, + headerActions, + toolbar, + status = "ready", + loadingLabel = "Connecting…", + errorMessage, + emptyMessage = "Nothing to show", + emptyIcon, + onRetry, + logEntries, + isConnecting, + isConnected, + hasConnectionError, + onLogClear, + scroll = false, + children, +}: { + title?: string; + subtitle?: string; + headerActions?: React.ReactNode; + toolbar?: React.ReactNode; + status?: SessionFrameStatus; + loadingLabel?: string; + errorMessage?: string; + emptyMessage?: string; + emptyIcon?: React.ReactNode; + onRetry?: () => void; + logEntries?: ConnectionLogEntry[]; + isConnecting?: boolean; + isConnected?: boolean; + hasConnectionError?: boolean; + onLogClear?: () => void; + scroll?: boolean; + children?: React.ReactNode; +}) { + const color = useThemeColor(); + + const Body: any = scroll ? ScrollView : View; + const bodyProps = scroll + ? { contentContainerStyle: { flexGrow: 1 } } + : { className: "flex-1" }; + + return ( + + {(title || headerActions) && ( + + + {title ? ( + + {title} + + ) : null} + {subtitle ? ( + + {subtitle} + + ) : null} + + {headerActions ? ( + {headerActions} + ) : null} + + )} + + {toolbar ? ( + {toolbar} + ) : null} + + {/* Connection log overlay — covers the frame while connecting/errored */} + {logEntries !== undefined && onLogClear !== undefined ? ( + + ) : null} + + {/* Suppress the loading spinner when the log overlay is covering the screen */} + {status === "loading" && !(logEntries && logEntries.length > 0 && !isConnected) ? ( + + + {loadingLabel} + + ) : status === "error" ? ( + + + + {errorMessage || "Something went wrong"} + + {onRetry ? ( + + ) : null} + + ) : status === "empty" ? ( + + {emptyIcon ?? } + + {emptyMessage} + + {onRetry ? ( + + ) : null} + + ) : ( + {children} + )} + + ); +} diff --git a/app/tabs/sessions/_shared/index.ts b/app/tabs/sessions/_shared/index.ts new file mode 100644 index 0000000..8426819 --- /dev/null +++ b/app/tabs/sessions/_shared/index.ts @@ -0,0 +1,11 @@ +export { usePolling } from "./usePolling"; +export { useConnectionLog, ConnectionLog } from "./useConnectionLog"; +export { SessionFrame } from "./SessionFrame"; +export type { SessionFrameStatus } from "./SessionFrame"; +export { ContextSheet } from "./ContextSheet"; +export type { ContextAction } from "./ContextSheet"; +export { + useSessionConnect, + type SessionConnectState, +} from "./useSessionConnect"; +export { AuthDialogs } from "./AuthDialogs"; diff --git a/app/tabs/sessions/_shared/useConnectionLog.tsx b/app/tabs/sessions/_shared/useConnectionLog.tsx new file mode 100644 index 0000000..225332d --- /dev/null +++ b/app/tabs/sessions/_shared/useConnectionLog.tsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { View, Pressable, ScrollView } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { + ChevronDown, + ChevronUp, + Copy, + Info, + CheckCircle2, + AlertTriangle, + XCircle, +} from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import type { + ConnectionLogEntry, + ConnectionLogLevel, + ConnectionLogPayload, +} from "@/types"; + +let logSeq = 0; + +/** + * Connection-log state for a session. Mirrors the web `connection-log`: connect + * endpoints return arrays of `{type, stage, message}` (sans id/timestamp); this + * stamps each with an id/timestamp and exposes append/ingest/clear plus the + * highest level seen (so the UI can auto-expand on error). + */ +export function useConnectionLog() { + const [entries, setEntries] = useState([]); + const highestLevelRef = useRef("info"); + + const append = useCallback((payload: ConnectionLogPayload) => { + const entry: ConnectionLogEntry = { + ...payload, + id: `log_${++logSeq}`, + timestamp: Date.now(), + }; + setEntries((prev) => [...prev, entry]); + if (rank(payload.level) > rank(highestLevelRef.current)) { + highestLevelRef.current = payload.level; + } + }, []); + + /** + * Ingest a backend `connectionLogs` array (the `type` field maps to `level`). + */ + const ingest = useCallback( + (logs: { type?: string; level?: string; stage?: string; message: string }[]) => { + for (const l of logs) { + const level = (l.level || l.type || "info") as ConnectionLogLevel; + append({ level, stage: l.stage, message: l.message }); + } + }, + [append], + ); + + const clear = useCallback(() => { + setEntries([]); + highestLevelRef.current = "info"; + }, []); + + return { + entries, + append, + ingest, + clear, + hasError: entries.some((e) => e.level === "error"), + }; +} + +function rank(level: ConnectionLogLevel): number { + switch (level) { + case "error": + return 3; + case "warning": + return 2; + case "success": + return 1; + default: + return 0; + } +} + +const LEVEL_ICON: Record< + ConnectionLogLevel, + (color: string) => React.ReactNode +> = { + info: (c) => , + success: (c) => , + warning: (c) => , + error: (c) => , +}; + +/** + * Full-screen connection-log overlay. Mirrors the web ConnectionLog: covers the + * entire session panel while connecting, auto-expands on error, auto-clears when + * the connection succeeds. The collapsed state shows only a slim bar at the bottom. + */ +export function ConnectionLog({ + entries, + isConnecting, + isConnected, + hasConnectionError, + onClear, +}: { + entries: ConnectionLogEntry[]; + isConnecting: boolean; + isConnected: boolean; + hasConnectionError: boolean; + onClear: () => void; +}) { + const color = useThemeColor(); + const [expanded, setExpanded] = useState(false); + const scrollRef = useRef(null); + + const shouldShow = + !isConnected && (isConnecting || hasConnectionError || entries.length > 0); + + // Auto-clear when successfully connected. + useEffect(() => { + if (isConnected && !hasConnectionError && !isConnecting) { + onClear(); + } + }, [isConnected, hasConnectionError, isConnecting, onClear]); + + // Auto-expand on error. + useEffect(() => { + if (hasConnectionError) { + setExpanded(true); + } + }, [hasConnectionError]); + + // Auto-scroll to latest entry when expanded. + useEffect(() => { + if (expanded && entries.length > 0) { + setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50); + } + }, [entries, expanded]); + + if (!shouldShow) return null; + + const levelColor = (level: ConnectionLogLevel): string => { + switch (level) { + case "error": + return color("destructive") ?? "#ef4444"; + case "warning": + return "#f59e0b"; + case "success": + return "#22c55e"; + default: + return color("muted-foreground") ?? "#888"; + } + }; + + const copyAll = async () => { + const text = entries + .map((e) => `[${e.level.toUpperCase()}] ${e.message}`) + .join("\n"); + await Clipboard.setStringAsync(text); + toast.success("Logs copied"); + }; + + const showBackground = expanded || hasConnectionError; + + return ( + + {/* Solid background when expanded */} + {showBackground && ( + + )} + + {/* Log panel */} + + {/* Header bar */} + setExpanded((v) => !v)} + className="flex-row items-center gap-2 px-3 py-2 active:bg-muted/30" + > + {expanded ? ( + + ) : ( + + )} + + Connection Log ({entries.length}) + + {entries.length > 0 && ( + + + + )} + + + {/* Log entries */} + {expanded && ( + + {entries.length === 0 ? ( + + {isConnecting ? "Waiting for connection…" : "No log entries"} + + ) : ( + entries.map((e) => ( + + + {LEVEL_ICON[e.level](levelColor(e.level))} + + + {e.message} + + + )) + )} + + )} + + + ); +} diff --git a/app/tabs/sessions/_shared/usePolling.ts b/app/tabs/sessions/_shared/usePolling.ts new file mode 100644 index 0000000..9401f6a --- /dev/null +++ b/app/tabs/sessions/_shared/usePolling.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react"; +import { AppState, type AppStateStatus } from "react-native"; + +/** + * Visibility-aware polling. Runs `fn` immediately when enabled, then on an + * interval — but pauses while the app is backgrounded (the OS suspends timers + * anyway, and we don't want a burst of stale requests on resume). Replaces the + * hardcoded `setInterval` that each session type used to roll on its own. + * + * `fn` is kept in a ref so callers can pass an inline closure without resetting + * the interval every render. The interval only restarts when `intervalMs` or + * `enabled` change. + */ +export function usePolling( + fn: () => void | Promise, + intervalMs: number, + enabled = true, +): void { + const fnRef = useRef(fn); + fnRef.current = fn; + + useEffect(() => { + if (!enabled || intervalMs <= 0) return; + + let timer: ReturnType | null = null; + let cancelled = false; + + const tick = () => { + if (!cancelled) void fnRef.current(); + }; + + const start = () => { + if (timer) return; + tick(); + timer = setInterval(tick, intervalMs); + }; + + const stop = () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }; + + const onAppStateChange = (state: AppStateStatus) => { + if (state === "active") start(); + else stop(); + }; + + if (AppState.currentState === "active") start(); + const sub = AppState.addEventListener("change", onAppStateChange); + + return () => { + cancelled = true; + stop(); + sub.remove(); + }; + }, [intervalMs, enabled]); +} diff --git a/app/tabs/sessions/_shared/useSessionConnect.ts b/app/tabs/sessions/_shared/useSessionConnect.ts new file mode 100644 index 0000000..501e297 --- /dev/null +++ b/app/tabs/sessions/_shared/useSessionConnect.ts @@ -0,0 +1,239 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppState } from "react-native"; +import { SSHHost, SessionAuthOverrides } from "@/types"; +import { getCurrentUserId } from "@/app/utils/user"; +import { extractConnectionLogs } from "@/app/main-axios"; +import { useConnectionLog } from "./useConnectionLog"; + +export type SessionConnectState = + | "idle" + | "connecting" + | "connected" + | "totp" + | "warpgate" + | "auth" + | "error"; + +/** + * The transport-specific calls a session type provides. File manager passes the + * `connectSSH`/`verifySSHTOTP`/`keepSSHAlive`/`disconnectSSH` family; docker + * passes its `dockerConnect`/`dockerConnectTOTP`/... family. This lets one + * connect state machine drive every session-based REST type. + */ +export interface SessionConnectTransport { + /** sessionId prefix, e.g. "fm" or "docker". */ + prefix: string; + connect: ( + sessionId: string, + host: SSHHost, + userId: string | undefined, + overrides: SessionAuthOverrides, + ) => Promise; + submitTotp: (sessionId: string, code: string) => Promise; + submitWarpgate?: ( + sessionId: string, + url: string, + securityKey?: string, + ) => Promise; + keepAlive?: (sessionId: string) => Promise; + disconnect?: (sessionId: string) => Promise; +} + +/** + * Drives the shared connect handshake for session-based REST connection types + * (file manager, docker). Owns: session id generation, the connect call, the + * TOTP / Warpgate / interactive-auth branches, keepalive, and a connection log. + * Mirrors the web's connect flow so mobile reaches auth-method parity. + * + * UI renders wired to the returned `auth*` props; on success the + * caller's `onConnected(sessionId)` runs (load the directory, list containers…). + */ +export function useSessionConnect( + host: SSHHost | null, + transport: SessionConnectTransport, + onConnected: (sessionId: string) => void | Promise, + options?: { keepAliveMs?: number; autoConnect?: boolean }, +) { + const log = useConnectionLog(); + const [state, setState] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const sessionIdRef = useRef(""); + const keepAliveRef = useRef | null>(null); + const overridesRef = useRef({}); + const onConnectedRef = useRef(onConnected); + onConnectedRef.current = onConnected; + + const keepAliveMs = options?.keepAliveMs ?? 30000; + + const stopKeepAlive = useCallback(() => { + if (keepAliveRef.current) { + clearInterval(keepAliveRef.current); + keepAliveRef.current = null; + } + }, []); + + const startKeepAlive = useCallback(() => { + if (!transport.keepAlive) return; + stopKeepAlive(); + keepAliveRef.current = setInterval(() => { + if (sessionIdRef.current && AppState.currentState === "active") { + transport.keepAlive?.(sessionIdRef.current).catch(() => {}); + } + }, keepAliveMs); + }, [transport, keepAliveMs, stopKeepAlive]); + + const markConnected = useCallback(async () => { + setState("connected"); + log.append({ level: "success", message: "Connected" }); + startKeepAlive(); + await onConnectedRef.current(sessionIdRef.current); + }, [log, startKeepAlive]); + + const runConnect = useCallback( + async (overrides: SessionAuthOverrides) => { + if (!host) return; + setState("connecting"); + setErrorMessage(""); + overridesRef.current = overrides; + try { + const userId = (await getCurrentUserId()) || undefined; + const sessionId = + sessionIdRef.current || + `${transport.prefix}-${host.id}-${Date.now()}`; + sessionIdRef.current = sessionId; + + log.append({ level: "info", message: `Connecting to ${host.name}…` }); + const result = await transport.connect( + sessionId, + host, + userId, + overrides, + ); + log.ingest(extractConnectionLogs(result)); + + if (result?.requiresTOTP || result?.totpRequired) { + setState("totp"); + return; + } + if (result?.requiresWarpgate || result?.warpgateRequired) { + setState("warpgate"); + return; + } + if ( + result?.requiresAuth || + result?.authRequired || + result?.code === "AUTH_REQUIRED" + ) { + setState("auth"); + return; + } + await markConnected(); + } catch (error: any) { + log.ingest(extractConnectionLogs(error)); + const msg = error?.message || "Failed to connect"; + setErrorMessage(msg); + log.append({ level: "error", message: msg }); + // Surface auth dialog rather than a dead error when the host needs creds. + if (error?.status === 401 || error?.code === "AUTH_REQUIRED") { + setState("auth"); + } else { + setState("error"); + } + } + }, + [host, transport, log, markConnected], + ); + + const connect = useCallback(() => { + sessionIdRef.current = ""; + log.clear(); + return runConnect({}); + }, [runConnect, log]); + + const retry = useCallback(() => connect(), [connect]); + + const submitTotp = useCallback( + async (code: string) => { + setState("connecting"); + try { + const result = await transport.submitTotp(sessionIdRef.current, code); + log.ingest(extractConnectionLogs(result)); + await markConnected(); + } catch (error: any) { + const msg = error?.message || "Invalid code"; + log.append({ level: "error", message: msg }); + setErrorMessage(msg); + setState("totp"); + throw error; + } + }, + [transport, log, markConnected], + ); + + const submitWarpgate = useCallback( + async (url: string, securityKey?: string) => { + if (!transport.submitWarpgate) return; + setState("connecting"); + try { + const result = await transport.submitWarpgate( + sessionIdRef.current, + url, + securityKey, + ); + log.ingest(extractConnectionLogs(result)); + await markConnected(); + } catch (error: any) { + const msg = error?.message || "Warpgate authentication failed"; + log.append({ level: "error", message: msg }); + setErrorMessage(msg); + setState("warpgate"); + throw error; + } + }, + [transport, log, markConnected], + ); + + const submitAuth = useCallback( + (overrides: SessionAuthOverrides) => runConnect(overrides), + [runConnect], + ); + + const cancelAuth = useCallback(() => { + setState("error"); + setErrorMessage("Authentication cancelled"); + }, []); + + const disconnect = useCallback(() => { + stopKeepAlive(); + if (sessionIdRef.current) { + transport.disconnect?.(sessionIdRef.current).catch(() => {}); + } + sessionIdRef.current = ""; + setState("idle"); + }, [transport, stopKeepAlive]); + + useEffect(() => { + if (options?.autoConnect && host && state === "idle") { + connect(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [host?.id, options?.autoConnect]); + + useEffect(() => stopKeepAlive, [stopKeepAlive]); + + return { + state, + errorMessage, + sessionId: sessionIdRef, + logEntries: log.entries, + logClear: log.clear, + connect, + retry, + disconnect, + // Auth dialog wiring + submitTotp, + submitWarpgate, + submitAuth, + cancelAuth, + }; +} diff --git a/app/tabs/sessions/docker/ContainerDetail.tsx b/app/tabs/sessions/docker/ContainerDetail.tsx new file mode 100644 index 0000000..57d54d1 --- /dev/null +++ b/app/tabs/sessions/docker/ContainerDetail.tsx @@ -0,0 +1,333 @@ +import { useCallback, useState } from "react"; +import { View, ScrollView, Pressable, ActivityIndicator } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { + ArrowLeft, + Copy, + Play, + Square, + RotateCcw, + Trash2, +} from "lucide-react-native"; +import { SSHHost, DockerContainer, DockerContainerAction } from "@/types"; +import { + getDockerContainerLogs, + getDockerContainerStats, +} from "@/app/main-axios"; +import { Text, Badge, SegmentedControl } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { toast } from "@/app/utils/toast"; +import { usePolling } from "@/app/tabs/sessions/_shared"; +import { DockerConsole } from "./DockerConsole"; +import type { DockerContainerStats } from "@/types"; + +type DetailTab = "logs" | "stats" | "console"; + +function isRunning(c: DockerContainer): boolean { + return /^up|running/i.test(c.state || c.status || ""); +} +function cleanName(name: string): string { + return name.startsWith("/") ? name.slice(1) : name; +} + +/** + * Full-frame container detail: header with quick actions + a Logs / Stats / + * Console tab strip. Logs poll; Stats poll (every 2s while running); Console is + * an interactive exec shell over the docker-console WebSocket. + */ +export function ContainerDetail({ + host, + sessionId, + container, + onBack, + onAction, +}: { + host: SSHHost; + sessionId: string; + container: DockerContainer; + onBack: () => void; + onAction: (c: DockerContainer, a: DockerContainerAction) => void; +}) { + const color = useThemeColor(); + const [tab, setTab] = useState("logs"); + const running = isRunning(container); + + return ( + + {/* Header */} + + + + + + + {cleanName(container.name)} + + + {container.image} + + + + {running ? "running" : container.state || "stopped"} + + + + {/* Quick actions */} + + {running ? ( + } + label="Stop" + onPress={() => onAction(container, "stop")} + /> + ) : ( + } + label="Start" + accent + onPress={() => onAction(container, "start")} + /> + )} + } + label="Restart" + onPress={() => onAction(container, "restart")} + /> + } + label="Remove" + destructive + onPress={() => { + onAction(container, "remove"); + onBack(); + }} + /> + + + {/* Tab strip */} + + + value={tab} + onChange={setTab} + options={[ + { id: "logs", label: "Logs" }, + { id: "stats", label: "Stats" }, + { id: "console", label: "Console" }, + ]} + /> + + + {/* Tab content */} + + {tab === "logs" ? ( + + ) : tab === "stats" ? ( + + ) : ( + + )} + + + ); +} + +function ActionChip({ + icon, + label, + onPress, + accent, + destructive, +}: { + icon: React.ReactNode; + label: string; + onPress: () => void; + accent?: boolean; + destructive?: boolean; +}) { + return ( + + {icon} + + {label} + + + ); +} + +function LogsTab({ + sessionId, + container, +}: { + sessionId: string; + container: DockerContainer; +}) { + const color = useThemeColor(); + const [logs, setLogs] = useState(""); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + try { + const text = await getDockerContainerLogs(sessionId, container.id, 300); + setLogs(text || "(no output)"); + } catch (e: any) { + setLogs(e?.message || "Failed to load logs"); + } finally { + setLoading(false); + } + }, [sessionId, container.id]); + + usePolling(load, 4000, true); + + const copy = async () => { + await Clipboard.setStringAsync(logs); + toast.success("Logs copied"); + }; + + if (loading) { + return ( + + + + ); + } + + return ( + + + + + Copy + + + + + {logs} + + + + ); +} + +function StatsTab({ + sessionId, + container, + running, +}: { + sessionId: string; + container: DockerContainer; + running: boolean; +}) { + const color = useThemeColor(); + const [stats, setStats] = useState(null); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + if (!running) return; + try { + const data = await getDockerContainerStats(sessionId, container.id); + setStats(data); + setError(""); + } catch (e: any) { + setError(e?.message || "Failed to load stats"); + } + }, [sessionId, container.id, running]); + + usePolling(load, 2000, running); + + if (!running) { + return ( + + + Container is not running — no live stats. + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!stats) { + return ( + + + + ); + } + + return ( + + + + + + + + ); +} + +function StatCard({ + label, + value, + sub, +}: { + label: string; + value: string; + sub?: string; +}) { + return ( + + + {label} + + + {value} + + {sub ? ( + {sub} + ) : null} + + ); +} diff --git a/app/tabs/sessions/docker/Docker.tsx b/app/tabs/sessions/docker/Docker.tsx index e49bbc1..bba0050 100644 --- a/app/tabs/sessions/docker/Docker.tsx +++ b/app/tabs/sessions/docker/Docker.tsx @@ -1,218 +1,389 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { View, ScrollView, Pressable, RefreshControl } from "react-native"; import { - View, - ScrollView, - Pressable, - RefreshControl, - ActivityIndicator, - Modal, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - Container, + Container as ContainerIcon, + Search, + RefreshCw, Play, Square, RotateCcw, + Pause, Trash2, - FileText, - X, + MoreVertical, } from "lucide-react-native"; -import { SSHHost } from "@/types"; +import { SSHHost, DockerContainer, DockerContainerAction } from "@/types"; import { + dockerConnect, + dockerConnectTOTP, + dockerKeepAlive, + dockerDisconnect, + dockerValidate, getDockerContainers, dockerContainerAction, - getDockerContainerLogs, - type DockerContainer, } from "@/app/main-axios"; -import { Text } from "@/app/components/ui"; +import { Text, Input, Badge, SegmentedControl } from "@/app/components/ui"; import { useThemeColor } from "@/app/contexts/ThemeContext"; -import { MONO_FONT } from "@/app/constants/fonts"; import { toast } from "@/app/utils/toast"; +import { + SessionFrame, + usePolling, + useSessionConnect, + AuthDialogs, + ContextSheet, + type ContextAction, +} from "@/app/tabs/sessions/_shared"; +import { ContainerDetail } from "./ContainerDetail"; interface DockerProps { host: SSHHost; isVisible: boolean; } +type StatusFilter = "all" | "running" | "stopped"; + +function isRunning(c: DockerContainer): boolean { + return /^up|running/i.test(c.state || c.status || ""); +} + +function cleanName(name: string): string { + return name.startsWith("/") ? name.slice(1) : name; +} + export function Docker({ host, isVisible }: DockerProps) { const color = useThemeColor(); - const insets = useSafeAreaInsets(); const [containers, setContainers] = useState([]); - const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [busy, setBusy] = useState(null); - const [logs, setLogs] = useState<{ name: string; text: string } | null>(null); + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [selected, setSelected] = useState(null); + const [menuFor, setMenuFor] = useState(null); + const [busyId, setBusyId] = useState(null); + const [dockerAvailable, setDockerAvailable] = useState(null); - const load = useCallback( - async (spinner = true) => { - if (spinner) setLoading(true); + const loadContainers = useCallback( + async (sessionId: string) => { try { - const list = await getDockerContainers(host.id); + const list = await getDockerContainers(sessionId); setContainers(list); } catch (e: any) { toast.error(e?.message || "Failed to load containers"); + } + }, + [], + ); + + const connectTransport = useMemo( + () => ({ + prefix: "docker", + connect: (sessionId: string, h: SSHHost) => + dockerConnect(sessionId, h.id), + submitTotp: (sessionId: string, code: string) => + dockerConnectTOTP(sessionId, code), + keepAlive: (sessionId: string) => dockerKeepAlive(sessionId), + disconnect: (sessionId: string) => dockerDisconnect(sessionId), + }), + [], + ); + + const onConnected = useCallback( + async (sessionId: string) => { + // Confirm Docker is actually installed before listing. + try { + const v = await dockerValidate(sessionId); + setDockerAvailable(v.available); + if (!v.available) return; + } catch { + setDockerAvailable(true); // assume available; list call will surface real errors + } + await loadContainers(sessionId); + }, + [loadContainers], + ); + + const conn = useSessionConnect( + isVisible ? host : null, + connectTransport, + onConnected, + { autoConnect: true, keepAliveMs: 30000 }, + ); + + // Poll the container list while connected and visible. + usePolling( + () => { + if (conn.state === "connected" && conn.sessionId.current) { + loadContainers(conn.sessionId.current); + } + }, + 5000, + isVisible && conn.state === "connected", + ); + + const runAction = useCallback( + async (c: DockerContainer, action: DockerContainerAction) => { + const sessionId = conn.sessionId.current; + if (!sessionId) return; + setBusyId(c.id); + try { + await dockerContainerAction(sessionId, c.id, action); + toast.success(`${action} · ${cleanName(c.name)}`); + await loadContainers(sessionId); + } catch (e: any) { + toast.error(e?.message || `Failed to ${action}`); } finally { - setLoading(false); - setRefreshing(false); + setBusyId(null); } }, - [host.id], + [conn.sessionId, loadContainers], ); - useEffect(() => { - if (isVisible) load(); - }, [isVisible, load]); - - const act = async ( - c: DockerContainer, - action: "start" | "stop" | "restart" | "remove", - ) => { - setBusy(c.id); - try { - await dockerContainerAction(host.id, c.id, action); - toast.success(`${action} ${c.name}`); - await load(false); - } catch (e: any) { - toast.error(e?.message || `Failed to ${action}`); - } finally { - setBusy(null); - } - }; - - const showLogs = async (c: DockerContainer) => { - setLogs({ name: c.name, text: "Loading…" }); - try { - const text = await getDockerContainerLogs(host.id, c.id); - setLogs({ name: c.name, text: text || "(no output)" }); - } catch (e: any) { - setLogs({ name: c.name, text: e?.message || "Failed to load logs" }); - } - }; + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return containers.filter((c) => { + if (statusFilter === "running" && !isRunning(c)) return false; + if (statusFilter === "stopped" && isRunning(c)) return false; + if (!q) return true; + return ( + cleanName(c.name).toLowerCase().includes(q) || + c.image?.toLowerCase().includes(q) || + c.id?.toLowerCase().includes(q) + ); + }); + }, [containers, query, statusFilter]); if (!isVisible) return null; + // Detail view takes over the whole frame. + if (selected) { + return ( + setSelected(null)} + onAction={runAction} + /> + ); + } + + // Map connect-state → SessionFrame status. + const frameStatus = + conn.state === "connecting" || conn.state === "idle" + ? "loading" + : conn.state === "error" + ? "error" + : dockerAvailable === false + ? "empty" + : filtered.length === 0 + ? "empty" + : "ready"; + return ( - - {loading && containers.length === 0 ? ( - - - - Loading containers… - - - ) : ( + <> + } + onRetry={conn.state === "error" ? conn.retry : undefined} + logEntries={conn.logEntries} + isConnecting={conn.state === "connecting" || conn.state === "idle"} + isConnected={conn.state === "connected"} + hasConnectionError={conn.state === "error"} + onLogClear={conn.logClear} + headerActions={ + + conn.sessionId.current && + loadContainers(conn.sessionId.current) + } + hitSlop={8} + className="p-1.5" + > + + + } + toolbar={ + conn.state === "connected" && dockerAvailable !== false ? ( + + } + /> + + value={statusFilter} + onChange={setStatusFilter} + options={[ + { id: "all", label: "All" }, + { id: "running", label: "Running" }, + { id: "stopped", label: "Stopped" }, + ]} + /> + + ) : undefined + } + > { + tintColor={color("accent-brand")} + onRefresh={async () => { setRefreshing(true); - load(false); + if (conn.sessionId.current) + await loadContainers(conn.sessionId.current); + setRefreshing(false); }} - tintColor={color("accent-brand")} /> } > - {containers.length === 0 ? ( - - No containers found - - ) : ( - containers.map((c) => { - const running = /up|running/i.test(c.state || c.status || ""); - return ( - - - - - - - {c.name} - - - {c.image} · {c.status} - - - {busy === c.id ? ( - - ) : null} - - - {running ? ( - } label="Stop" onPress={() => act(c, "stop")} /> - ) : ( - } label="Start" onPress={() => act(c, "start")} accent /> - )} - } label="Restart" onPress={() => act(c, "restart")} /> - } label="Logs" onPress={() => showLogs(c)} /> - } label="Remove" onPress={() => act(c, "remove")} destructive /> - - - ); - }) - )} + {filtered.map((c) => ( + setSelected(c)} + onMenu={() => setMenuFor(c)} + /> + ))} - )} - - {/* Logs viewer */} - setLogs(null)}> - - - - {logs?.name} logs - - setLogs(null)} hitSlop={8}> - - - - - - {logs?.text} - - - - - + + + {/* Container action menu */} + setMenuFor(null)} + title={menuFor ? cleanName(menuFor.name) : undefined} + subtitle={menuFor?.image} + actions={buildActions(menuFor, color, runAction)} + /> + + {/* Shared auth dialogs (TOTP / Warpgate / interactive) */} + + ); } -function DockerBtn({ - icon, - label, +function buildActions( + c: DockerContainer | null, + color: ReturnType, + run: (c: DockerContainer, a: DockerContainerAction) => void, +): (ContextAction | null)[] { + if (!c) return []; + const running = isRunning(c); + const fg = color("foreground") ?? "#fafafa"; + return [ + running + ? null + : { + key: "start", + icon: , + label: "Start", + onPress: () => run(c, "start"), + }, + running + ? { + key: "stop", + icon: , + label: "Stop", + onPress: () => run(c, "stop"), + } + : null, + { + key: "restart", + icon: , + label: "Restart", + onPress: () => run(c, "restart"), + }, + running + ? { + key: "pause", + icon: , + label: "Pause", + onPress: () => run(c, "pause"), + } + : { + key: "unpause", + icon: , + label: "Unpause", + onPress: () => run(c, "unpause"), + }, + { + key: "remove", + icon: , + label: "Remove", + destructive: true, + onPress: () => run(c, "remove"), + }, + ]; +} + +function ContainerRow({ + container, + busy, onPress, - accent, - destructive, + onMenu, }: { - icon: React.ReactNode; - label: string; + container: DockerContainer; + busy: boolean; onPress: () => void; - accent?: boolean; - destructive?: boolean; + onMenu: () => void; }) { + const color = useThemeColor(); + const running = isRunning(container); return ( - {icon} - - {label} - + + + + + + {cleanName(container.name)} + + + {container.image} + + + + {running ? "running" : container.state || "stopped"} + + + + + ); } diff --git a/app/tabs/sessions/docker/DockerConsole.tsx b/app/tabs/sessions/docker/DockerConsole.tsx new file mode 100644 index 0000000..98bd89b --- /dev/null +++ b/app/tabs/sessions/docker/DockerConsole.tsx @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { View, ActivityIndicator, Pressable } from "react-native"; +import { WebView } from "react-native-webview"; +import { RotateCcw } from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { + getCookie, + getDockerConsoleWebSocketUrl, +} from "@/app/main-axios"; +import type { SSHHost, DockerContainer } from "@/types"; + +/** + * Interactive `docker exec` console. Renders a minimal xterm.js inside a WebView + * (same approach as the SSH Terminal) and bridges it to the backend docker + * console WebSocket (port 30009). Connect → input/resize/output round-trip. + * + * Kept deliberately small: this is a shell into a container, not the full + * terminal experience (no command history / snippets), so it skips the heavier + * Terminal machinery. + */ +export function DockerConsole({ + host, + container, + isVisible, +}: { + host: SSHHost; + container: DockerContainer; + isVisible: boolean; +}) { + const color = useThemeColor(); + const webViewRef = useRef(null); + const wsRef = useRef(null); + const [status, setStatus] = useState< + "connecting" | "connected" | "error" | "closed" + >("connecting"); + const [errorMessage, setErrorMessage] = useState(""); + const [webViewKey, setWebViewKey] = useState(0); + + const send = useCallback((msg: object) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(msg)); + } + }, []); + + const connect = useCallback(async () => { + setStatus("connecting"); + setErrorMessage(""); + const token = await getCookie("jwt"); + if (!token) { + setStatus("error"); + setErrorMessage("Not authenticated"); + return; + } + try { + wsRef.current?.close(); + } catch {} + + const ws = new WebSocket(getDockerConsoleWebSocketUrl(token)); + wsRef.current = ws; + + ws.onopen = () => { + send({ + type: "connect", + data: { + hostConfig: { id: host.id, enableDocker: true }, + containerId: container.id, + cols: 80, + rows: 24, + }, + }); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string); + if (msg.type === "output") { + // Feed raw output into xterm in the WebView. + webViewRef.current?.injectJavaScript( + `window.__write(${JSON.stringify(msg.data)}); true;`, + ); + } else if (msg.type === "connected") { + setStatus("connected"); + } else if (msg.type === "error") { + setStatus("error"); + setErrorMessage(msg.message || "Console error"); + } else if (msg.type === "disconnected") { + setStatus("closed"); + } + } catch {} + }; + + ws.onerror = () => { + setStatus("error"); + setErrorMessage("Connection failed"); + }; + + ws.onclose = () => { + setStatus((s) => (s === "error" ? s : "closed")); + }; + }, [host.id, container.id, send]); + + useEffect(() => { + connect(); + return () => { + try { + send({ type: "disconnect" }); + wsRef.current?.close(); + } catch {} + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [webViewKey]); + + const onWebViewMessage = useCallback( + (event: { nativeEvent: { data: string } }) => { + try { + const msg = JSON.parse(event.nativeEvent.data); + if (msg.type === "input") { + send({ type: "input", data: msg.data }); + } else if (msg.type === "resize") { + send({ type: "resize", data: { cols: msg.cols, rows: msg.rows } }); + } + } catch {} + }, + [send], + ); + + const reconnect = () => setWebViewKey((k) => k + 1); + + if (!isVisible) return null; + + return ( + + + {status !== "connected" ? ( + + {status === "connecting" ? ( + <> + + + Attaching to {container.name}… + + + ) : ( + <> + + {errorMessage || "Console disconnected"} + + + + Reconnect + + + )} + + ) : null} + + ); +} + +const CONSOLE_HTML = ` + + + + + + + + + +
+ + +`; diff --git a/app/tabs/sessions/file-manager/FileManager.tsx b/app/tabs/sessions/file-manager/FileManager.tsx index 10666de..884f303 100644 --- a/app/tabs/sessions/file-manager/FileManager.tsx +++ b/app/tabs/sessions/file-manager/FileManager.tsx @@ -1,35 +1,61 @@ import { useState, - useEffect, - useRef, useCallback, + useMemo, forwardRef, useImperativeHandle, + useRef, } from "react"; import { View, + ScrollView, + Pressable, + RefreshControl, Alert, - TextInput, Modal, - Text, - TouchableOpacity, - ActivityIndicator, + TextInput, KeyboardAvoidingView, Platform, + Animated, + ActivityIndicator, } from "react-native"; +import { Swipeable } from "react-native-gesture-handler"; 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"; +import * as Clipboard from "expo-clipboard"; +import * as DocumentPicker from "expo-document-picker"; import { - BORDERS, - BORDER_COLORS, - RADIUS, - BACKGROUNDS, -} from "@/app/constants/designTokens"; + ChevronRight, + File as FileIcon, + Folder, + Link as LinkIcon, + Search, + RefreshCw, + FolderPlus, + FilePlus, + ClipboardPaste, + Eye, + Pencil, + Copy, + Scissors, + Trash2, + Lock, + ClipboardCopy, + MoreVertical, + ChevronLeft, + ArrowUpDown, + Upload, + Archive, + CheckSquare, + Square, + X, +} from "lucide-react-native"; +import { SSHHost, SessionAuthOverrides } from "@/types"; import { connectSSH, + verifySSHTOTP, + verifySSHWarpgate, + keepSSHAlive, + disconnectSSH, listSSHFiles, readSSHFile, writeSSHFile, @@ -39,22 +65,35 @@ import { renameSSHItem, copySSHItem, moveSSHItem, - verifySSHTOTP, - keepSSHAlive, + changeSSHPermissions, identifySSHSymlink, + uploadSSHFile, + extractSSHArchive, } from "@/app/main-axios"; -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 { Text, Input, Button } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { + SessionFrame, + useSessionConnect, + AuthDialogs, + ContextSheet, + type ContextAction, +} from "@/app/tabs/sessions/_shared"; +import { FileViewer } from "./FileViewer"; +import { PermissionsDialog } from "./PermissionsDialog"; import { joinPath, isTextFile, isArchiveFile, -} from "@/app/tabs/sessions/file-manager/utils/fileUtils"; -import { showToast } from "@/app/utils/toast"; -import { TOTPDialog } from "@/app/tabs/dialogs"; + formatFileSize, + formatDate, + getFileIconColor, + breadcrumbsFromPath, + getBreadcrumbLabel, + getParentPath, + sortFiles, +} from "./utils/fileUtils"; interface FileManagerProps { host: SSHHost; @@ -69,674 +108,1078 @@ interface FileItem { size?: number; modified?: string; permissions?: string; + owner?: string; + group?: string; } export interface FileManagerHandle { handleDisconnect: () => void; } +type SortBy = "name" | "size" | "modified"; + export const FileManager = forwardRef( - ({ host, sessionId, isVisible }, ref) => { + ({ host, isVisible }, ref) => { + const color = useThemeColor(); const insets = useSafeAreaInsets(); - const { width, isLandscape } = useOrientation(); + const [currentPath, setCurrentPath] = useState("/"); const [files, setFiles] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isConnected, setIsConnected] = useState(false); - const [sshSessionId, setSshSessionId] = useState(null); + const [loadingDir, setLoadingDir] = useState(false); + const [, setBusy] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [query, setQuery] = useState(""); + + const [sortBy, setSortBy] = useState("name"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const [selectionMode, setSelectionMode] = useState(false); - const [selectedFiles, setSelectedFiles] = useState([]); const [clipboard, setClipboard] = useState<{ files: string[]; operation: "copy" | "cut" | null; }>({ files: [], operation: null }); - 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 [selectionMode, setSelectionMode] = useState(false); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + + const [menuFile, setMenuFile] = useState(null); + const [moreMenuVisible, setMoreMenuVisible] = useState(false); + const [permsFile, setPermsFile] = useState(null); + const [createDialog, setCreateDialog] = useState<"file" | "folder" | null>(null); const [createName, setCreateName] = useState(""); - const [renameDialog, setRenameDialog] = useState<{ - visible: boolean; - file: FileItem | null; - }>({ visible: false, file: null }); + const [renameTarget, setRenameTarget] = useState(null); const [renameName, setRenameName] = useState(""); - const [fileViewer, setFileViewer] = useState<{ - visible: boolean; - file: FileItem | null; + const [viewer, setViewer] = useState<{ + file: FileItem; content: string; - }>({ visible: false, file: null, content: "" }); - - const keepaliveInterval = useRef | null>(null); - - 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, - userId: host.userId, - forceKeyboardInteractive: host.forceKeyboardInteractive, - overrideCredentialUsername: host.overrideCredentialUsername, - jumpHosts: host.jumpHosts, - }); - - if (response.requires_totp) { - setTotpDialog(true); - return; - } - - setSshSessionId(sessionId); - setIsConnected(true); - - keepaliveInterval.current = setInterval(() => { - keepSSHAlive(sessionId).catch(() => {}); - }, 30000); - - await loadDirectory(host.defaultPath || "/"); - } catch (error: any) { - showToast.error(error.message || "Failed to connect to SSH"); - } finally { - setIsLoading(false); - } - }, [host, sessionId]); + } | null>(null); - const handleTOTPVerify = async (code: string) => { - try { - await verifySSHTOTP(sessionId, code); - setTotpDialog(false); - setTotpCode(""); - setSshSessionId(sessionId); - setIsConnected(true); - - keepaliveInterval.current = setInterval(() => { - keepSSHAlive(sessionId).catch(() => {}); - }, 30000); - - await loadDirectory(host.defaultPath || "/"); - } catch (error: any) { - showToast.error(error.message || "Invalid TOTP code"); - } - }; + // --- Transport --- + const transport = useMemo( + () => ({ + prefix: "fm", + connect: ( + sid: string, + h: SSHHost, + userId: string | undefined, + overrides: SessionAuthOverrides, + ) => + connectSSH(sid, { + hostId: h.id, + ip: h.ip, + port: h.port, + username: h.username, + password: + overrides.userProvidedPassword ?? + (h.authType === "password" ? h.password : undefined), + sshKey: + overrides.userProvidedSshKey ?? + (h.authType === "key" ? h.key : undefined), + keyPassword: overrides.userProvidedKeyPassword ?? h.keyPassword, + authType: h.authType, + credentialId: h.credentialId, + userId, + forceKeyboardInteractive: h.forceKeyboardInteractive, + overrideCredentialUsername: h.overrideCredentialUsername, + jumpHosts: h.jumpHosts, + }), + submitTotp: (sid: string, code: string) => verifySSHTOTP(sid, code), + submitWarpgate: (sid: string, url: string, key?: string) => + verifySSHWarpgate(sid, url, key), + keepAlive: (sid: string) => keepSSHAlive(sid), + disconnect: (sid: string) => disconnectSSH(sid), + }), + [], + ); const loadDirectory = useCallback( - async (path: string) => { - if (!sessionId) return; - + async (path: string, sid?: string) => { + const session = sid || conn.sessionId.current; + if (!session) return; + setBusy(true); + setLoadingDir(true); try { - setIsLoading(true); - const response = await listSSHFiles(sessionId, path); - setFiles(response.files || []); + const response = await listSSHFiles(session, path); + setFiles((response.files as FileItem[]) || []); setCurrentPath(response.path || path); - } catch (error: any) { - showToast.error(error.message || "Failed to load directory"); + setSelectedFiles(new Set()); + setSelectionMode(false); + } catch (e: any) { + toast.error(e?.message || "Failed to load directory"); } finally { - setIsLoading(false); + setBusy(false); + setLoadingDir(false); } }, - [sessionId], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const onConnected = useCallback( + (sid: string) => loadDirectory(host.defaultPath || "/", sid), + [host.defaultPath, loadDirectory], + ); + + const conn = useSessionConnect( + isVisible && host.enableFileManager ? host : null, + transport, + onConnected, + { autoConnect: true, keepAliveMs: 30000 }, ); - const handleFilePress = async (file: FileItem) => { + useImperativeHandle(ref, () => ({ + handleDisconnect: () => conn.disconnect(), + })); + + // --- File interactions --- + const openFile = async (file: FileItem) => { + if (selectionMode) { + toggleSelect(file.path); + return; + } if (file.type === "link") { try { - setIsLoading(true); - const symlinkInfo = await identifySSHSymlink(sessionId!, file.path); - - if (symlinkInfo.type === "directory") { - await loadDirectory(symlinkInfo.target); - } else if (isTextFile(symlinkInfo.target)) { - const targetFile: FileItem = { - name: file.name, - path: symlinkInfo.target, - type: "file", - }; - await handleViewFile(targetFile); + setBusy(true); + const info = await identifySSHSymlink(conn.sessionId.current, file.path); + if (info.type === "directory") { + await loadDirectory(info.target); + } else if (isTextFile(info.target)) { + await viewFile({ ...file, path: info.target, type: "file" }); } else { - showToast.info("File type not supported for viewing"); + toast.info("File type not supported for viewing"); } - } catch (error: any) { - showToast.error(error.message || "Failed to follow symlink"); + } catch (e: any) { + toast.error(e?.message || "Failed to follow symlink"); } finally { - setIsLoading(false); + setBusy(false); } return; } - if (file.type === "directory") { loadDirectory(file.path); } else { - handleViewFile(file); + viewFile(file); } }; - const handleFileLongPress = (file: FileItem) => { - setContextMenu({ visible: true, file }); - }; - - const handleViewFile = async (file: FileItem) => { + const viewFile = 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(error.message || "Failed to read file"); + setBusy(true); + const response = await readSSHFile(conn.sessionId.current, file.path); + setViewer({ file, content: response.content }); + } catch (e: any) { + toast.error(e?.message || "Failed to read file"); } finally { - setIsLoading(false); + setBusy(false); } }; - const handleSaveFile = async (content: string) => { - if (!fileViewer.file) return; - - try { - await writeSSHFile(sessionId!, fileViewer.file.path, content, host.id); - showToast.success("File saved successfully"); - await loadDirectory(currentPath); - } catch (error: any) { - throw new Error(error.message || "Failed to save file"); - } - }; - - const handleCreateFolder = () => { - setCreateDialog({ visible: true, type: "folder" }); - setCreateName(""); + const saveFile = async (content: string) => { + if (!viewer) return; + await writeSSHFile(conn.sessionId.current, viewer.file.path, content, host.id); + toast.success("File saved"); + await loadDirectory(currentPath); }; - const handleCreateFile = () => { - setCreateDialog({ visible: true, type: "file" }); - setCreateName(""); - }; - - const handleCreateConfirm = async () => { - if (!createDialog.type || !createName.trim()) return; - + const confirmCreate = async () => { + if (!createDialog || !createName.trim()) return; try { - setIsLoading(true); - if (createDialog.type === "folder") { - await createSSHFolder(sessionId!, currentPath, createName, host.id); - showToast.success("Folder created successfully"); + setBusy(true); + if (createDialog === "folder") { + await createSSHFolder(conn.sessionId.current, currentPath, createName, host.id); } else { - await createSSHFile(sessionId!, currentPath, createName, "", host.id); - showToast.success("File created successfully"); + await createSSHFile(conn.sessionId.current, currentPath, createName, "", host.id); } - setCreateDialog({ visible: false, type: null }); + toast.success(`${createDialog === "folder" ? "Folder" : "File"} created`); + setCreateDialog(null); setCreateName(""); await loadDirectory(currentPath); - } catch (error: any) { - showToast.error(error.message || "Failed to create item"); + } catch (e: any) { + toast.error(e?.message || "Failed to create"); } finally { - setIsLoading(false); + setBusy(false); } }; - const handleRename = (file: FileItem) => { - setRenameDialog({ visible: true, file }); - setRenameName(file.name); - }; - - const handleRenameConfirm = async () => { - if (!renameDialog.file || !renameName.trim()) return; - + const confirmRename = async () => { + if (!renameTarget || !renameName.trim()) return; try { - setIsLoading(true); - await renameSSHItem( - sessionId!, - renameDialog.file.path, - renameName, - host.id, - ); - showToast.success("Item renamed successfully"); - setRenameDialog({ visible: false, file: null }); + setBusy(true); + await renameSSHItem(conn.sessionId.current, renameTarget.path, renameName, host.id); + toast.success("Renamed"); + setRenameTarget(null); setRenameName(""); await loadDirectory(currentPath); - } catch (error: any) { - showToast.error(error.message || "Failed to rename item"); + } catch (e: any) { + toast.error(e?.message || "Failed to rename"); } finally { - setIsLoading(false); + setBusy(false); } }; - const handleCopy = (file?: FileItem) => { - const filesToCopy = file ? [file.path] : selectedFiles; - setClipboard({ files: filesToCopy, operation: "copy" }); - setSelectionMode(false); - setSelectedFiles([]); - showToast.success(`${filesToCopy.length} item(s) copied`); + const doDelete = (file: FileItem) => { + Alert.alert("Delete", `Delete "${file.name}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + setBusy(true); + await deleteSSHItem( + conn.sessionId.current, + file.path, + file.type === "directory", + host.id, + ); + toast.success("Deleted"); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to delete"); + } finally { + setBusy(false); + } + }, + }, + ]); }; - const handleCut = (file?: FileItem) => { - const filesToCut = file ? [file.path] : selectedFiles; - setClipboard({ files: filesToCut, operation: "cut" }); - setSelectionMode(false); - setSelectedFiles([]); - showToast.success(`${filesToCut.length} item(s) cut`); + const doDeleteSelected = () => { + const count = selectedFiles.size; + Alert.alert("Delete", `Delete ${count} item${count !== 1 ? "s" : ""}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + setBusy(true); + for (const path of selectedFiles) { + const file = files.find((f) => f.path === path); + await deleteSSHItem( + conn.sessionId.current, + path, + file?.type === "directory", + host.id, + ); + } + toast.success(`${count} item${count !== 1 ? "s" : ""} deleted`); + setSelectionMode(false); + setSelectedFiles(new Set()); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to delete"); + } finally { + setBusy(false); + } + }, + }, + ]); }; - const handlePaste = async () => { - if (clipboard.files.length === 0 || !clipboard.operation) return; - + const doPaste = async () => { + if (!clipboard.files.length || !clipboard.operation) return; try { - setIsLoading(true); - for (const filePath of clipboard.files) { + setBusy(true); + for (const p of clipboard.files) { if (clipboard.operation === "copy") { - await copySSHItem(sessionId!, filePath, currentPath, host.id); + await copySSHItem(conn.sessionId.current, p, currentPath, host.id); } else { await moveSSHItem( - sessionId!, - filePath, - joinPath(currentPath, filePath.split("/").pop()!), + conn.sessionId.current, + p, + joinPath(currentPath, p.split("/").pop()!), host.id, ); } } - showToast.success(`${clipboard.files.length} item(s) pasted`); + toast.success(`${clipboard.files.length} item(s) pasted`); setClipboard({ files: [], operation: null }); await loadDirectory(currentPath); - } catch (error: any) { - showToast.error(error.message || "Failed to paste items"); + } catch (e: any) { + toast.error(e?.message || "Failed to paste"); } finally { - setIsLoading(false); + setBusy(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.success(`${filesToDelete.length} item(s) deleted`); - setSelectionMode(false); - setSelectedFiles([]); - await loadDirectory(currentPath); - } catch (error: any) { - showToast.error(error.message || "Failed to delete items"); - } finally { - setIsLoading(false); - } - }, - }, - ], - ); + const applyPermissions = async (octal: string) => { + if (!permsFile) return; + try { + await changeSSHPermissions(conn.sessionId.current, permsFile.path, octal, host.id); + toast.success(`Permissions set to ${octal}`); + setPermsFile(null); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to change permissions"); + } }; - const handleSelectToggle = (path: string) => { - setSelectedFiles((prev) => - prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], - ); + const doUpload = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + }); + if (result.canceled || !result.assets?.[0]) return; + const asset = result.assets[0]; + setBusy(true); + const response = await fetch(asset.uri); + const buffer = await response.arrayBuffer(); + const base64 = btoa( + String.fromCharCode(...new Uint8Array(buffer)), + ); + await uploadSSHFile(conn.sessionId.current, currentPath, asset.name, base64, host.id); + toast.success(`Uploaded ${asset.name}`); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to upload file"); + } finally { + setBusy(false); + } }; - const handleCancelSelection = () => { + const doExtract = async (file: FileItem) => { + try { + setBusy(true); + await extractSSHArchive(conn.sessionId.current, file.path, currentPath, host.id); + toast.success("Extracted successfully"); + await loadDirectory(currentPath); + } catch (e: any) { + toast.error(e?.message || "Failed to extract archive"); + } finally { + setBusy(false); + } + }; + + // --- Selection helpers --- + const toggleSelect = (path: string) => { + setSelectedFiles((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const activateSelection = (file: FileItem) => { + setSelectionMode(true); + setSelectedFiles(new Set([file.path])); + }; + + const cancelSelection = () => { setSelectionMode(false); - setSelectedFiles([]); + setSelectedFiles(new Set()); }; - useEffect(() => { - connectToSSH(); + const copySelected = () => { + setClipboard({ files: Array.from(selectedFiles), operation: "copy" }); + cancelSelection(); + toast.success(`${selectedFiles.size} item(s) copied`); + }; - return () => { - if (keepaliveInterval.current) { - clearInterval(keepaliveInterval.current); - } - }; - }, [connectToSSH]); + const cutSelected = () => { + setClipboard({ files: Array.from(selectedFiles), operation: "cut" }); + cancelSelection(); + toast.success(`${selectedFiles.size} item(s) cut`); + }; - useImperativeHandle(ref, () => ({ - handleDisconnect: () => { - if (keepaliveInterval.current) { - clearInterval(keepaliveInterval.current); + // --- Sort cycling: tap once to switch field, tap again to flip order --- + const SORT_FIELDS: SortBy[] = ["name", "size", "modified"]; + const cycleSortBy = () => { + setSortBy((prevField) => { + setSortOrder((prevOrder) => { + if (prevOrder === "asc") return "desc"; + // was desc → advance to next field, reset to asc + return "asc"; + }); + // Only advance field when flipping from desc back to asc + // We do this by reading sortOrder directly (stale closure is fine here — + // we just need the value at the moment of the press) + if (sortOrder === "desc") { + const idx = SORT_FIELDS.indexOf(prevField); + return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]; } - setIsConnected(false); - }, - })); + return prevField; + }); + }; + + const sortLabel: Record = { name: "Name", size: "Size", modified: "Date" }; + + // --- Filtered + sorted files --- + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const list = q ? files.filter((f) => f.name.toLowerCase().includes(q)) : files; + return sortFiles(list, sortBy, sortOrder); + }, [files, query, sortBy, sortOrder]); + + if (!isVisible) return null; 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}... - - { - setTotpDialog(false); - setTotpCode(""); - }} - prompt="Two-Factor Authentication" - isPasswordPrompt={false} - /> - - ); - } + const frameStatus = + conn.state === "connecting" || conn.state === "idle" + ? "loading" + : conn.state === "error" + ? "error" + : "ready"; - const padding = getResponsivePadding(isLandscape); - const tabBarHeight = getTabBarHeight(isLandscape); + const breadcrumbs = breadcrumbsFromPath(currentPath); + const isConnected = conn.state === "connected"; - const toolbarPaddingVertical = isLandscape ? 8 : 12; - const toolbarContentHeight = isLandscape ? 34 : 44; - const toolbarBorderHeight = 2; - const effectiveToolbarHeight = - selectionMode || clipboard.files.length > 0 - ? toolbarPaddingVertical * 2 + - toolbarContentHeight + - toolbarBorderHeight - : 0; + // More-menu actions + const moreActions: (ContextAction | null)[] = [ + { + key: "new-folder", + icon: , + label: "New Folder", + onPress: () => setCreateDialog("folder"), + }, + { + key: "new-file", + icon: , + label: "New File", + onPress: () => setCreateDialog("file"), + }, + { + key: "upload", + icon: , + label: "Upload File", + onPress: doUpload, + }, + clipboard.files.length > 0 + ? { + key: "paste", + icon: , + label: `Paste ${clipboard.files.length} item${clipboard.files.length !== 1 ? "s" : ""}`, + onPress: doPaste, + } + : null, + { + key: "refresh", + icon: , + label: "Refresh", + onPress: () => loadDirectory(currentPath), + }, + ]; return ( - - loadDirectory(currentPath)} - onCreateFolder={handleCreateFolder} - onCreateFile={handleCreateFile} - onMenuPress={() => setSelectionMode(true)} - isLoading={isLoading} - isLandscape={isLandscape} - /> - - loadDirectory(currentPath)} - isLandscape={isLandscape} - width={width} - toolbarHeight={effectiveToolbarHeight} - /> - - handleCopy()} - onCut={() => handleCut()} - onPaste={handlePaste} - onDelete={() => handleDelete()} - onCancelSelection={handleCancelSelection} - onCancelClipboard={() => setClipboard({ files: [], operation: null })} - clipboardCount={clipboard.files.length} - clipboardOperation={clipboard.operation} - isLandscape={isLandscape} - bottomInset={insets.bottom} - tabBarHeight={tabBarHeight} - /> + <> + + {/* Nav row */} + + {/* Back / up */} + loadDirectory(getParentPath(currentPath))} + hitSlop={8} + className="p-1.5 rounded active:bg-muted/40" + disabled={currentPath === "/"} + style={{ opacity: currentPath === "/" ? 0.35 : 1 }} + > + + - {contextMenu.file && ( - setContextMenu({ visible: false, file: null })} - fileName={contextMenu.file.name} - fileType={contextMenu.file.type} - onView={ - contextMenu.file.type === "file" - ? () => handleViewFile(contextMenu.file!) - : undefined - } - onEdit={ - contextMenu.file.type === "file" - ? () => 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)} - /> - )} + {/* Breadcrumb scroll */} + + {breadcrumbs.map((bc, i) => ( + + {i > 0 ? ( + + ) : null} + loadDirectory(bc)} + className="px-1 py-1 rounded active:bg-muted/40" + hitSlop={4} + > + + {getBreadcrumbLabel(bc)} + + + + ))} + - - - - - - Create New{" "} - {createDialog.type === "folder" ? "Folder" : "File"} - - - - { - setCreateDialog({ visible: false, type: null }); - setCreateName(""); - }} - className="flex-1 py-3" - style={{ - backgroundColor: BACKGROUNDS.BUTTON, - borderWidth: BORDERS.MAJOR, - borderColor: BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} + {/* Sort toggle */} + - - Cancel + + + {sortLabel[sortBy]} {sortOrder === "asc" ? "↑" : "↓"} - - + + {/* More menu */} + setMoreMenuVisible(true)} + hitSlop={8} + className="p-1.5 rounded active:bg-muted/40" > - - Create - - + +
+
+ + {/* Search row */} + + } + className="h-8 text-xs" + />
-
- - - - - - - 0 ? 80 : 16 }} + refreshControl={ + { + setRefreshing(true); + await loadDirectory(currentPath); + setRefreshing(false); }} - > - - Rename Item - - + } + > + {loadingDir && !refreshing ? ( + + + + ) : filtered.length === 0 ? ( + + {query ? "No matching files" : "Empty folder"} + + ) : ( + filtered.map((file) => ( + openFile(file)} + onLongPress={() => { + if (selectionMode) { + toggleSelect(file.path); + } else { + activateSelection(file); + } }} - value={renameName} - onChangeText={setRenameName} - placeholder="New name" - placeholderTextColor="#6B7280" - autoFocus + onMenu={() => setMenuFile(file)} + onDelete={() => doDelete(file)} + color={color} /> - - { - setRenameDialog({ visible: false, file: null }); - setRenameName(""); - }} - className="flex-1 py-3" - style={{ - backgroundColor: BACKGROUNDS.BUTTON, - borderWidth: BORDERS.MAJOR, - borderColor: BORDER_COLORS.BUTTON, - borderRadius: RADIUS.BUTTON, - }} - activeOpacity={0.7} + )) + )} + + + {/* Bottom action bar — selection or clipboard */} + {(selectionMode || clipboard.files.length > 0) && ( + + + {selectionMode ? ( + <> + + {selectedFiles.size} selected + + - - Cancel - - - +
+ - - Rename - -
-
-
+ +
+ + + + + + + + ) : clipboard.files.length > 0 ? ( + <> + + {clipboard.files.length} item{clipboard.files.length !== 1 ? "s" : ""}{" "} + {clipboard.operation === "copy" ? "copied" : "cut"} + + + + Paste + + setClipboard({ files: [], operation: null })} + hitSlop={6} + className="p-2 rounded border border-border active:bg-muted/40" + > + + + + ) : null} +
- - + )} + - {fileViewer.file && ( + {/* Per-file action menu */} + setMenuFile(null)} + title={menuFile?.name} + subtitle={menuFile?.path} + actions={buildFileActions(menuFile, color, { + view: viewFile, + rename: (f) => { + setRenameTarget(f); + setRenameName(f.name); + }, + copy: (f) => setClipboard({ files: [f.path], operation: "copy" }), + cut: (f) => setClipboard({ files: [f.path], operation: "cut" }), + perms: (f) => setPermsFile(f), + copyPath: async (f) => { + await Clipboard.setStringAsync(f.path); + toast.success("Path copied"); + }, + del: doDelete, + extract: doExtract, + })} + /> + + {/* More / overflow menu */} + setMoreMenuVisible(false)} + title="Actions" + actions={moreActions} + /> + + {/* Permissions editor */} + setPermsFile(null)} + onApply={applyPermissions} + /> + + {/* Create / rename dialogs */} + { + setCreateDialog(null); + setCreateName(""); + }} + onConfirm={confirmCreate} + confirmLabel="Create" + insetBottom={insets.bottom} + /> + { + setRenameTarget(null); + setRenameName(""); + }} + onConfirm={confirmRename} + confirmLabel="Rename" + insetBottom={insets.bottom} + /> + + {/* Text/code viewer + editor */} + {viewer ? ( - setFileViewer({ visible: false, file: null, content: "" }) - } - fileName={fileViewer.file.name} - filePath={fileViewer.file.path} - initialContent={fileViewer.content} - onSave={handleSaveFile} + visible + onClose={() => setViewer(null)} + fileName={viewer.file.name} + filePath={viewer.file.path} + initialContent={viewer.content} + onSave={saveFile} /> - )} -
+ ) : null} + + {/* Shared auth dialogs */} + + ); }, ); FileManager.displayName = "FileManager"; + +// ─── buildFileActions ──────────────────────────────────────────────────────── + +function buildFileActions( + file: FileItem | null, + color: ReturnType, + handlers: { + view: (f: FileItem) => void; + rename: (f: FileItem) => void; + copy: (f: FileItem) => void; + cut: (f: FileItem) => void; + perms: (f: FileItem) => void; + copyPath: (f: FileItem) => void; + del: (f: FileItem) => void; + extract: (f: FileItem) => void; + }, +): (ContextAction | null)[] { + if (!file) return []; + const fg = color("foreground") ?? "#fafafa"; + const isFile = file.type === "file"; + return [ + isFile && isTextFile(file.name) + ? { + key: "view", + icon: , + label: "View / Edit", + onPress: () => handlers.view(file), + } + : null, + { + key: "rename", + icon: , + label: "Rename", + onPress: () => handlers.rename(file), + }, + { + key: "copy", + icon: , + label: "Copy", + onPress: () => handlers.copy(file), + }, + { + key: "cut", + icon: , + label: "Cut", + onPress: () => handlers.cut(file), + }, + isFile && isArchiveFile(file.name) + ? { + key: "extract", + icon: , + label: "Extract Here", + onPress: () => handlers.extract(file), + } + : null, + { + key: "perms", + icon: , + label: "Permissions", + onPress: () => handlers.perms(file), + }, + { + key: "copyPath", + icon: , + label: "Copy Path", + onPress: () => handlers.copyPath(file), + }, + { + key: "delete", + icon: , + label: "Delete", + destructive: true, + onPress: () => handlers.del(file), + }, + ]; +} + +// ─── FileRow ───────────────────────────────────────────────────────────────── + +function FileRow({ + file, + selected, + selectionMode, + onPress, + onLongPress, + onMenu, + onDelete, + color, +}: { + file: FileItem; + selected: boolean; + selectionMode: boolean; + onPress: () => void; + onLongPress: () => void; + onMenu: () => void; + onDelete: () => void; + color: ReturnType; +}) { + const swipeableRef = useRef(null); + const iconColor = getFileIconColor(file.name, file.type); + const Icon = + file.type === "directory" ? Folder : file.type === "link" ? LinkIcon : FileIcon; + const isDir = file.type === "directory"; + + const renderRightActions = ( + progress: Animated.AnimatedInterpolation, + dragX: Animated.AnimatedInterpolation, + ) => { + const scale = dragX.interpolate({ + inputRange: [-80, 0], + outputRange: [1, 0.85], + extrapolate: "clamp", + }); + return ( + + { + swipeableRef.current?.close(); + onDelete(); + }} + style={{ flex: 1, justifyContent: "center", alignItems: "center", width: "100%" }} + > + + + + ); + }; + + const rowContent = ( + + {/* Selection checkbox */} + {selectionMode ? ( + + {selected ? ( + + ) : ( + + )} + + ) : null} + + {/* File icon */} + + + {/* Name + meta */} + + + {file.name} + + + {isDir ? ( + Folder + ) : ( + <> + {file.size !== undefined ? ( + + {formatFileSize(file.size)} + + ) : null} + {file.modified ? ( + <> + {file.size !== undefined ? ( + · + ) : null} + + {formatDate(file.modified)} + + + ) : null} + + )} + {file.permissions ? ( + <> + · + + {file.permissions} + + + ) : null} + + + + {/* Right indicator */} + {isDir ? ( + + ) : !selectionMode ? ( + + + + ) : null} + + ); + + // In selection mode, disable swipe + if (selectionMode) { + return rowContent; + } + + return ( + + {rowContent} + + ); +} + +// ─── NameDialog ────────────────────────────────────────────────────────────── + +function NameDialog({ + visible, + title, + value, + onChange, + onClose, + onConfirm, + confirmLabel, + insetBottom, +}: { + visible: boolean; + title: string; + value: string; + onChange: (v: string) => void; + onClose: () => void; + onConfirm: () => void; + confirmLabel: string; + insetBottom: number; +}) { + const color = useThemeColor(); + return ( + + + + e.stopPropagation()} + > + + {title} + + + + + + + + + + + ); +} diff --git a/app/tabs/sessions/file-manager/FileViewer.tsx b/app/tabs/sessions/file-manager/FileViewer.tsx index cce7e79..c274229 100644 --- a/app/tabs/sessions/file-manager/FileViewer.tsx +++ b/app/tabs/sessions/file-manager/FileViewer.tsx @@ -2,17 +2,18 @@ import { useState, useEffect } from "react"; import { Modal, View, - Text, - TouchableOpacity, TextInput, ActivityIndicator, Alert, Platform, KeyboardAvoidingView, + Pressable, } 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 { X, Save, RotateCcw, FileText } from "lucide-react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; import { useOrientation } from "@/app/utils/orientation"; interface FileViewerProps { @@ -26,7 +27,7 @@ interface FileViewerProps { } const MONOSPACE_FONT = Platform.select({ - ios: "Courier", + ios: "Courier New", android: "monospace", default: "monospace", }); @@ -42,6 +43,7 @@ export function FileViewer({ }: FileViewerProps) { const insets = useSafeAreaInsets(); const { isLandscape } = useOrientation(); + const color = useThemeColor(); const [content, setContent] = useState(initialContent); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); @@ -58,13 +60,12 @@ export function FileViewer({ const handleSave = async () => { if (!hasChanges || readOnly) return; - try { setIsSaving(true); await onSave(content); setHasChanges(false); } catch (error: any) { - showToast.error(error.message || "Failed to save file"); + toast.error(error.message || "Failed to save file"); } finally { setIsSaving(false); } @@ -72,45 +73,32 @@ export function FileViewer({ 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); - }, + Alert.alert("Revert Changes", "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(); - }, + Alert.alert("Unsaved Changes", "Save before closing?", [ + { text: "Cancel", style: "cancel" }, + { text: "Discard", style: "destructive", onPress: onClose }, + { + text: "Save", + onPress: async () => { + await handleSave(); + onClose(); }, - ], - ); + }, + ]); } else { onClose(); } @@ -126,104 +114,90 @@ export function FileViewer({ > + {/* Header */} - - - - {fileName} - - - {filePath} - - - - - {!readOnly && hasChanges && ( - <> - - - - - - {isSaving ? ( - - ) : ( - - )} - - - )} - - - - - + + + + {fileName} + + + {filePath} + - {readOnly && ( - - Read-only mode - - )} + + {!readOnly && hasChanges ? ( + <> + + + + + {isSaving ? ( + + ) : ( + + )} + + + ) : null} + + + + + {readOnly ? ( + + Read-only + + ) : null} + diff --git a/app/tabs/sessions/file-manager/PermissionsDialog.tsx b/app/tabs/sessions/file-manager/PermissionsDialog.tsx new file mode 100644 index 0000000..a9ff14d --- /dev/null +++ b/app/tabs/sessions/file-manager/PermissionsDialog.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; +import { View, Pressable } from "react-native"; +import { Dialog, Button, Text } from "@/app/components/ui"; + +type Bit = "r" | "w" | "x"; +const CLASSES = ["owner", "group", "other"] as const; +const BITS: Bit[] = ["r", "w", "x"]; +const BIT_VALUE: Record = { r: 4, w: 2, x: 1 }; + +/** Parse a permissions string (octal "755" or symbolic "rwxr-xr-x") to 3 digits. */ +function parsePermissions(perms?: string): [number, number, number] { + if (!perms) return [7, 5, 5]; + const octal = perms.match(/([0-7])([0-7])([0-7])\s*$/); + if (octal) { + return [Number(octal[1]), Number(octal[2]), Number(octal[3])]; + } + // Symbolic: take the last 9 chars (rwxrwxrwx), ignoring the type char. + const sym = perms.replace(/[^rwx-]/g, ""); + const tail = sym.slice(-9); + if (tail.length === 9) { + const calc = (g: string) => + (g[0] === "r" ? 4 : 0) + (g[1] === "w" ? 2 : 0) + (g[2] === "x" ? 1 : 0); + return [calc(tail.slice(0, 3)), calc(tail.slice(3, 6)), calc(tail.slice(6, 9))]; + } + return [7, 5, 5]; +} + +/** + * chmod editor. Shows owner/group/other read/write/execute toggles plus the + * resulting octal, and calls onApply with the 3-digit octal string. + */ +export function PermissionsDialog({ + visible, + fileName, + permissions, + onClose, + onApply, +}: { + visible: boolean; + fileName: string; + permissions?: string; + onClose: () => void; + onApply: (octal: string) => void; +}) { + const [digits, setDigits] = useState<[number, number, number]>([7, 5, 5]); + + useEffect(() => { + if (visible) setDigits(parsePermissions(permissions)); + }, [visible, permissions]); + + const has = (idx: number, bit: Bit) => (digits[idx] & BIT_VALUE[bit]) !== 0; + const toggle = (idx: number, bit: Bit) => { + setDigits((prev) => { + const next: [number, number, number] = [...prev] as any; + next[idx] = next[idx] ^ BIT_VALUE[bit]; + return next; + }); + }; + + const octal = digits.join(""); + + return ( + + + +
+ } + > + + {CLASSES.map((cls, idx) => ( + + + {cls} + + + {BITS.map((bit) => { + const active = has(idx, bit); + return ( + toggle(idx, bit)} + className={`flex-1 py-2 items-center border ${ + active + ? "bg-accent-brand/10 border-accent-brand/40" + : "border-border active:bg-muted/40" + }`} + > + + {bit} + + + ); + })} + + + ))} + + + ); +} diff --git a/app/tabs/sessions/file-manager/utils/fileUtils.ts b/app/tabs/sessions/file-manager/utils/fileUtils.ts index 1d90051..98ea217 100644 --- a/app/tabs/sessions/file-manager/utils/fileUtils.ts +++ b/app/tabs/sessions/file-manager/utils/fileUtils.ts @@ -35,47 +35,25 @@ export function joinPath(...parts: string[]): string { } export function isTextFile(filename: string): boolean { + // Dotfiles with no extension (e.g. .bashrc, .zshrc, .profile) are always text + if (filename.startsWith(".") && !filename.slice(1).includes(".")) return true; + 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", + + // No extension at all — treat as text (e.g. Makefile, Dockerfile, LICENSE) + if (!ext) return true; + + const binaryExtensions = [ + "png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "svg", + "mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", + "mp3", "wav", "ogg", "flac", "aac", "m4a", + "zip", "tar", "gz", "bz2", "xz", "7z", "rar", + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "exe", "bin", "so", "dylib", "dll", "class", "pyc", + "db", "sqlite", "sqlite3", + "woff", "woff2", "ttf", "otf", "eot", ]; - return textExtensions.includes(ext); + return !binaryExtensions.includes(ext); } export function isArchiveFile(filename: string): boolean { diff --git a/app/tabs/sessions/navigation/TabBar.tsx b/app/tabs/sessions/navigation/TabBar.tsx index e9381de..e29d0fc 100644 --- a/app/tabs/sessions/navigation/TabBar.tsx +++ b/app/tabs/sessions/navigation/TabBar.tsx @@ -6,6 +6,7 @@ import { ScrollView, TextInput, Keyboard, + StyleSheet, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -15,6 +16,13 @@ import { Minus, ChevronDown, ChevronUp, + SquareTerminal, + Activity, + Folder, + Network, + Container, + Monitor, + Layers, } from "lucide-react-native"; import { SessionType, @@ -29,8 +37,27 @@ import { BORDER_COLORS, BACKGROUNDS, RADIUS, + ACCENT, + TEXT_COLORS, } from "@/app/constants/designTokens"; +function getSessionIcon(type: SessionType) { + switch (type) { + case "terminal": + return SquareTerminal; + case "stats": + return Activity; + case "filemanager": + return Folder; + case "tunnel": + return Network; + case "docker": + return Container; + case "remoteDesktop": + return Monitor; + } +} + interface TabBarProps { sessions: TerminalSession[]; activeSessionId: string | null; @@ -44,6 +71,8 @@ interface TabBarProps { onShowKeyboard?: () => void; keyboardIntentionallyHiddenRef: React.MutableRefObject; activeSessionType?: SessionType; + onShowConnections?: () => void; + hasBackgroundSessions?: boolean; } export default function TabBar({ @@ -59,6 +88,8 @@ export default function TabBar({ onShowKeyboard, keyboardIntentionallyHiddenRef, activeSessionType, + onShowConnections, + hasBackgroundSessions, }: TabBarProps) { const router = useRouter(); const { isKeyboardVisible } = useKeyboard(); @@ -90,12 +121,10 @@ export default function TabBar({ + {/* Connections panel button */} + + + + + {hasBackgroundSessions && ( + + )} + + + {/* Back to hosts button */} router.navigate("/hosts" as any)} focusable={false} @@ -119,7 +189,7 @@ export default function TabBar({ style={{ width: buttonSize, height: buttonSize, - borderWidth: BORDERS.STANDARD, + borderWidth: StyleSheet.hairlineWidth, borderColor: BORDER_COLORS.BUTTON, backgroundColor: BACKGROUNDS.BUTTON, borderRadius: RADIUS.BUTTON, @@ -162,6 +232,8 @@ export default function TabBar({ > {sessions.map((session) => { const isActive = session.id === activeSessionId; + const SessionIcon = getSessionIcon(session.type); + const iconColor = isActive ? ACCENT : TEXT_COLORS.SECONDARY; return ( - + + {session.title} @@ -206,18 +286,18 @@ export default function TabBar({ className="items-center justify-center" activeOpacity={0.7} style={{ - width: isLandscape ? 32 : 36, + width: isLandscape ? 28 : 32, height: buttonSize, - borderLeftWidth: BORDERS.STANDARD, + borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: isActive ? BORDER_COLORS.ACTIVE : BORDER_COLORS.BUTTON, }} > @@ -235,7 +315,7 @@ export default function TabBar({ style={{ width: buttonSize, height: buttonSize, - borderWidth: BORDERS.STANDARD, + borderWidth: StyleSheet.hairlineWidth, borderColor: BORDER_COLORS.BUTTON, backgroundColor: BACKGROUNDS.BUTTON, borderRadius: RADIUS.BUTTON, @@ -264,20 +344,20 @@ export default function TabBar({ style={{ width: buttonSize, height: buttonSize, - borderWidth: BORDERS.STANDARD, - borderColor: BORDER_COLORS.BUTTON, - backgroundColor: BACKGROUNDS.BUTTON, + borderWidth: StyleSheet.hairlineWidth, + borderColor: isCustomKeyboardVisible + ? BORDER_COLORS.ACTIVE + : BORDER_COLORS.BUTTON, + backgroundColor: isCustomKeyboardVisible + ? `${ACCENT}18` + : BACKGROUNDS.BUTTON, borderRadius: RADIUS.BUTTON, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, elevation: 2, marginLeft: isLandscape ? 6 : 8, }} > {isCustomKeyboardVisible ? ( - + ) : ( )} @@ -285,18 +365,6 @@ export default function TabBar({ )} - {activeSessionType === "terminal" && isCustomKeyboardVisible && ( - - )} ); } diff --git a/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx b/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx index 768a3f7..771fc9d 100644 --- a/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx +++ b/app/tabs/sessions/remote-desktop/RemoteDesktop.tsx @@ -7,21 +7,38 @@ import React, { } from "react"; import { ActivityIndicator, + Keyboard, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, - useWindowDimensions, View, } from "react-native"; import { WebView } from "react-native-webview"; -import { Keyboard, RotateCcw } from "lucide-react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Keyboard as KeyboardIcon, + RotateCcw, + Settings2, + X, +} from "lucide-react-native"; import type { SSHHost } from "@/types"; import { getGuacamoleTokenFromHost, getGuacamoleWebSocketUrl, } from "@/app/main-axios"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { BottomSheet, SegmentedControl, Button } from "@/app/components/ui"; + +// Height of the always-visible key strip at the bottom. +// The parent session area already accounts for the tab bar + safe-area insets, +// so we only need our own strip height here. +const KEY_STRIP_HEIGHT = 44; type ConnectionState = | "idle" @@ -30,6 +47,15 @@ type ConnectionState = | "disconnected" | "failed"; +type MouseMode = "touch" | "trackpad"; + +interface Modifiers { + ctrl: boolean; + alt: boolean; + shift: boolean; + win: boolean; +} + interface RemoteDesktopProps { host: SSHHost; isVisible: boolean; @@ -38,36 +64,87 @@ interface RemoteDesktopProps { } export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { - const { width, height } = useWindowDimensions(); - const initialSizeRef = useRef({ - width: Math.round(width), - height: Math.round(height), - }); + const color = useThemeColor(); + const { bottom: safeBottom } = useSafeAreaInsets(); + + const themeBg = color("background") ?? "rgb(12,13,11)"; + const themeCard = color("card") ?? "rgb(24,25,23)"; + const themeBorder = color("border") ?? "rgb(50,50,50)"; + const themeFg = color("foreground") ?? "rgb(250,250,250)"; + const themeMuted = color("muted-foreground") ?? "rgb(164,164,164)"; + const themeAccent = color("accent-brand") ?? "#f59145"; + + // True available size measured from onLayout — avoids using full window + // height which includes areas already consumed by the tab bar and insets. + const [availableSize, setAvailableSize] = useState<{ w: number; h: number } | null>(null); + const availableSizeRef = useRef<{ w: number; h: number } | null>(null); + const containerRef = useRef(null); + + const handleContainerLayout = useCallback((e: any) => { + const { width: lw, height: lh } = e.nativeEvent.layout; + const w = Math.round(lw); + const h = Math.round(lh); + availableSizeRef.current = { w, h }; + setAvailableSize({ w, h }); + }, []); + + // Stores the resolved initial size once connect() runs + const initialSizeRef = useRef({ width: 1280, height: 720 }); + const webViewRef = useRef(null); const inputRef = useRef(null); + const [connectionState, setConnectionState] = useState("idle"); const [errorMessage, setErrorMessage] = useState(null); const [webSocketUrl, setWebSocketUrl] = useState(null); const [webViewKey, setWebViewKey] = useState(0); - const [inputValue, setInputValue] = useState(""); + + const [mouseMode, setMouseMode] = useState("touch"); + const [modifiers, setModifiers] = useState({ + ctrl: false, + alt: false, + shift: false, + win: false, + }); + const [showFunctionKeys, setShowFKeys] = useState(false); + const [showSettingsSheet, setShowSettings] = useState(false); + const [zoomLevel, setZoomLevel] = useState(1.0); + const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + // Track keyboard visibility — use the raw keyboard height minus the bottom + // margin that Sessions.tsx already applies (tab bar + safe area insets). + // We get the container's page position at show-time to compute exact overlap. + useEffect(() => { + const show = Keyboard.addListener("keyboardDidShow", (e) => { + setIsKeyboardOpen(true); + setKeyboardHeight(e.endCoordinates.height); + }); + const hide = Keyboard.addListener("keyboardDidHide", () => { + setIsKeyboardOpen(false); + setKeyboardHeight(0); + }); + return () => { show.remove(); hide.remove(); }; + }, []); + + // ── Connection ──────────────────────────────────────────────────────────── const connect = useCallback(async () => { try { setConnectionState("connecting"); setErrorMessage(null); - const { token } = await getGuacamoleTokenFromHost(Number(host.id)); - const initialSize = initialSizeRef.current; - setWebSocketUrl( - getGuacamoleWebSocketUrl(token, initialSize.width, initialSize.height), - ); + // Use measured layout size; fall back to ref if layout fired already + const measured = availableSizeRef.current; + const remW = measured ? measured.w : 1280; + const remH = measured ? Math.max(1, measured.h - KEY_STRIP_HEIGHT) : 720; + initialSizeRef.current = { width: remW, height: remH }; + setWebSocketUrl(getGuacamoleWebSocketUrl(token, remW, remH)); } catch (error) { setConnectionState("failed"); setErrorMessage( - error instanceof Error - ? error.message - : "Failed to start remote session", + error instanceof Error ? error.message : "Failed to start remote session", ); } }, [host.id]); @@ -79,7 +156,6 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { const handleMessage = useCallback((event: any) => { try { const payload = JSON.parse(event.nativeEvent.data); - if (payload.type === "state") { if (payload.state === "connected") { setConnectionState("connected"); @@ -90,6 +166,8 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { } else if (payload.type === "error") { setConnectionState("failed"); setErrorMessage(payload.message || "Remote session failed"); + } else if (payload.type === "zoomChange") { + setZoomLevel(payload.zoom); } } catch { setConnectionState("failed"); @@ -97,6 +175,8 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { } }, []); + // ── WebView HTML ────────────────────────────────────────────────────────── + const htmlContent = useMemo(() => { if (!webSocketUrl) return ""; @@ -105,22 +185,36 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { @@ -130,191 +224,564 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { `; }, [webSocketUrl]); + // ── Reconnect ───────────────────────────────────────────────────────────── + const reconnect = useCallback(() => { setWebSocketUrl(null); - setWebViewKey((current) => current + 1); + setZoomLevel(1.0); + setModifiers({ ctrl: false, alt: false, shift: false, win: false }); + setWebViewKey((k) => k + 1); }, []); - const injectRemoteCommand = useCallback((script: string) => { + // ── JS bridge helpers ───────────────────────────────────────────────────── + + const inject = useCallback((script: string) => { webViewRef.current?.injectJavaScript(`${script}; true;`); }, []); const sendKeysym = useCallback( - (keysym: number) => { - injectRemoteCommand( - `window.termixRemote && window.termixRemote.sendKeysym(${keysym})`, - ); - }, - [injectRemoteCommand], + (k: number) => inject(`window.termixRemote && window.termixRemote.sendKeysym(${k})`), + [inject], ); const sendKeysyms = useCallback( - (keysyms: number[]) => { - injectRemoteCommand( - `window.termixRemote && window.termixRemote.sendKeysyms(${JSON.stringify(keysyms)})`, - ); - }, - [injectRemoteCommand], + (ks: number[]) => inject(`window.termixRemote && window.termixRemote.sendKeysyms(${JSON.stringify(ks)})`), + [inject], ); const sendText = useCallback( - (text: string) => { - injectRemoteCommand( - `window.termixRemote && window.termixRemote.sendText(${JSON.stringify(text)})`, - ); - }, - [injectRemoteCommand], + (t: string) => inject(`window.termixRemote && window.termixRemote.sendText(${JSON.stringify(t)})`), + [inject], ); - const handleInputChange = useCallback( - (text: string) => { - if (text) { - sendText(text); - } - setInputValue(""); + const sendWithMods = useCallback( + (k: number) => { + inject(`window.termixRemote && window.termixRemote.sendWithModifiers(${k}, ${JSON.stringify(modifiers)})`); + setModifiers({ ctrl: false, alt: false, shift: false, win: false }); }, - [sendText], + [inject, modifiers], ); - const protocol = (host.connectionType || "rdp").toUpperCase(); + const toggleModifier = useCallback( + (key: keyof Modifiers) => setModifiers((m) => ({ ...m, [key]: !m[key] })), + [], + ); + + const resetZoom = useCallback(() => { + inject("window.termixRemote && window.termixRemote.resetZoom()"); + setZoomLevel(1.0); + }, [inject]); + const canSendInput = connectionState === "connected"; + const protocol = (host.connectionType || "rdp").toUpperCase(); - useEffect(() => { - if (!canSendInput) return; + // The keyboard height event is from the screen bottom. Our container's bottom + // already sits SESSION_TAB_BAR_HEIGHT + safeBottom above the screen bottom + // (applied by Sessions.tsx). The actual overlap on our container is: + const SESSION_TAB_BAR_HEIGHT = 62; // getTabBarHeight(portrait) + 2 + const kbOverlap = Math.max(0, keyboardHeight - SESSION_TAB_BAR_HEIGHT - safeBottom); - injectRemoteCommand( - `window.termixRemote && window.termixRemote.resize(${Math.round(width)}, ${Math.round(height)})`, + // Tell remote to render at the true available size when connected or when + // the container size changes (orientation change) or keyboard opens/closes. + useEffect(() => { + if (!canSendInput || !availableSize) return; + const remW = availableSize.w; + const remH = Math.max(1, availableSize.h - KEY_STRIP_HEIGHT - kbOverlap); + inject( + `window.termixRemote && window.termixRemote.resize(${remW}, ${remH})`, ); - }, [canSendInput, height, injectRemoteCommand, width]); + }, [canSendInput, availableSize, kbOverlap, inject]); + + // ── Styles ──────────────────────────────────────────────────────────────── + + const styles = useMemo( + () => + StyleSheet.create({ + // Outer wrapper: absolute-fills the parent session area. + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#000", + }, + // WebView fills everything except the key strip at the bottom. + webView: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: KEY_STRIP_HEIGHT, + backgroundColor: "#000", + }, + // Connecting/error overlay: fills the full area (strip not visible yet). + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + backgroundColor: themeBg, + }, + overlayTitle: { + marginTop: 12, + color: themeFg, + fontSize: 16, + fontWeight: "600", + textAlign: "center", + }, + overlayText: { + marginTop: 8, + color: themeMuted, + fontSize: 13, + lineHeight: 18, + textAlign: "center", + }, + // Key strip: sits at the very bottom of the parent session area + keyStrip: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + height: KEY_STRIP_HEIGHT, + backgroundColor: themeBg, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: themeBorder, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 4, + }, + scrollContent: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 4, + }, + // Key button in the strip + key: { + height: 34, + paddingHorizontal: 9, + borderRadius: 6, + borderWidth: 1, + borderColor: themeBorder, + backgroundColor: themeCard, + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: 4, + }, + keyText: { + color: themeFg, + fontSize: 12, + fontWeight: "600", + }, + // Modifier pill (active = accent tinted) + modKey: { + height: 34, + paddingHorizontal: 9, + borderRadius: 6, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, + modKeyText: { + fontSize: 12, + fontWeight: "700", + }, + divider: { + width: 1, + height: 22, + backgroundColor: themeBorder, + marginHorizontal: 2, + }, + iconKey: { + width: 36, + height: 34, + borderRadius: 6, + borderWidth: 1, + borderColor: themeBorder, + backgroundColor: themeCard, + alignItems: "center", + justifyContent: "center", + }, + hiddenInput: { + position: "absolute", + width: 1, + height: 1, + opacity: 0, + color: "transparent", + }, + // Settings sheet sections + sheetSection: { + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: themeBorder, + gap: 8, + }, + sheetLabel: { + color: themeMuted, + fontSize: 10, + fontWeight: "700", + textTransform: "uppercase" as const, + letterSpacing: 1, + }, + sheetDesc: { + color: themeMuted, + fontSize: 11, + lineHeight: 16, + }, + zoomRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 16, + paddingVertical: 12, + }, + zoomLabel: { + color: themeMuted, + fontSize: 12, + flex: 1, + }, + }), + [themeBg, themeCard, themeBorder, themeFg, themeMuted], + ); + + const modKeyStyle = (active: boolean) => ({ + ...styles.modKey, + borderColor: active ? themeAccent : themeBorder, + backgroundColor: active ? themeAccent : themeCard, + }); + + const modKeyTextStyle = (active: boolean) => ({ + ...styles.modKeyText, + color: active ? "#fff" : themeMuted, + }); + + const FKEYS = useMemo(() => Array.from({ length: 12 }, (_, i) => 0xffbe + i), []); + + // ── Render ──────────────────────────────────────────────────────────────── return ( + {/* WebView */} {htmlContent ? ( { - setConnectionState("failed"); - setErrorMessage(event.nativeEvent.description); - }} - onHttpError={(event) => { - setConnectionState("failed"); - setErrorMessage( - `WebView HTTP error: ${event.nativeEvent.statusCode}`, - ); - }} + onError={(e) => { setConnectionState("failed"); setErrorMessage(e.nativeEvent.description); }} + onHttpError={(e) => { setConnectionState("failed"); setErrorMessage(`HTTP ${e.nativeEvent.statusCode}`); }} scrollEnabled={false} overScrollMode="never" bounces={false} @@ -324,185 +791,214 @@ export function RemoteDesktop({ host, isVisible, title }: RemoteDesktopProps) { /> ) : null} + {/* Connecting overlay */} {(connectionState === "connecting" || connectionState === "idle") && ( - + Connecting {protocol} {title} )} + {/* Failed / disconnected overlay */} {(connectionState === "failed" || connectionState === "disconnected") && ( - {connectionState === "failed" - ? "Connection Failed" - : "Disconnected"} + {connectionState === "failed" ? "Connection Failed" : "Disconnected"} {errorMessage ? ( {errorMessage} ) : null} - - - Reconnect - + )} + {/* Hidden text input for keyboard */} + { if (text) sendText(text); }} + onKeyPress={({ nativeEvent }) => { + if (nativeEvent.key === "Backspace") sendKeysym(0xff08); + }} + onSubmitEditing={() => { + sendKeysym(0xff0d); + // Re-focus so the keyboard stays open after Enter + setTimeout(() => inputRef.current?.focus(), 10); + }} + blurOnSubmit={false} + returnKeyType="send" + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + style={styles.hiddenInput} + /> + + {/* ── Key strip ── */} {canSendInput && ( - - { - if (nativeEvent.key === "Backspace") { - sendKeysym(0xff08); - } else if (nativeEvent.key === "Enter") { - sendKeysym(0xff0d); - } - }} - autoCapitalize="none" - autoCorrect={false} - spellCheck={false} - style={styles.hiddenInput} - /> + - } - onPress={() => inputRef.current?.focus()} - /> - sendKeysym(0xff1b)} /> - sendKeysym(0xff09)} /> - sendKeysym(0xff0d)} /> - sendKeysym(0xff08)} /> - sendKeysym(0xff51)} /> - sendKeysym(0xff52)} /> - sendKeysym(0xff54)} /> - sendKeysym(0xff53)} /> - sendKeysyms([0xffe3, 0xffe9, 0xffff])} - /> + {/* Modifier keys */} + {(["ctrl", "alt", "shift", "win"] as const).map((k) => ( + toggleModifier(k)}> + + {k === "ctrl" ? "Ctrl" : k === "alt" ? "Alt" : k === "shift" ? "⇧" : "Win"} + + + ))} + + + + {/* Keyboard toggle */} + {isKeyboardOpen ? ( + Keyboard.dismiss()} + > + + Done + + ) : ( + inputRef.current?.focus()} + > + + + )} + + + + {/* Common keys */} + {[ + { label: "Esc", k: 0xff1b }, + { label: "Tab", k: 0xff09 }, + ].map(({ label, k }) => ( + sendWithMods(k)}> + {label} + + ))} + + + + {/* Arrow keys */} + sendWithMods(0xff51)}> + + + sendWithMods(0xff52)}> + + + sendWithMods(0xff54)}> + + + sendWithMods(0xff53)}> + + + + + + {/* Nav keys */} + {[ + { label: "Home", k: 0xff50 }, + { label: "End", k: 0xff57 }, + { label: "PgUp", k: 0xff55 }, + { label: "PgDn", k: 0xff56 }, + { label: "Bksp", k: 0xff08 }, + { label: "Del", k: 0xffff }, + { label: "Enter", k: 0xff0d }, + ].map(({ label, k }) => ( + sendWithMods(k)}> + {label} + + ))} + + + + {/* System combos */} + sendKeysyms([0xffe3, 0xffe9, 0xffff])}> + CAD + + sendKeysyms([0xffeb])}> + Win + + + + + {/* F-key toggle */} + setShowFKeys((v) => !v)} + > + Fn + + + {/* F1–F12 (shown inline when toggled) */} + {showFunctionKeys && FKEYS.map((ks, i) => ( + sendWithMods(ks)}> + F{i + 1} + + ))} + + + + {/* Settings */} + setShowSettings(true)}> + + )} - - ); -} -function RemoteKeyButton({ - label, - icon, - onPress, -}: { - label: string; - icon?: React.ReactNode; - onPress: () => void; -}) { - return ( - - {icon} - {label} - + {/* ── Settings sheet (mouse mode + zoom only — compact) ── */} + setShowSettings(false)} + title="Remote Settings" + scroll={false} + > + {/* Mouse mode */} + + Mouse Mode + { + setMouseMode(m); + inject(`window.termixRemote && window.termixRemote.setMouseMode('${m}')`); + }} + /> + + {mouseMode === "touch" + ? "Tap directly where you want to click." + : "Drag to move pointer. Single tap = click. Two-finger tap = right-click."} + + + + {/* Zoom */} + + + Zoom: {zoomLevel.toFixed(1)}× — pinch anywhere to zoom + + + + + ); } - -const styles = StyleSheet.create({ - container: { - ...StyleSheet.absoluteFillObject, - backgroundColor: "#050608", - }, - webView: { - flex: 1, - width: "100%", - height: "100%", - backgroundColor: "#050608", - }, - overlay: { - ...StyleSheet.absoluteFillObject, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: 24, - backgroundColor: "#050608", - }, - overlayTitle: { - marginTop: 12, - color: "#ffffff", - fontSize: 16, - fontWeight: "600", - textAlign: "center", - }, - overlayText: { - marginTop: 8, - color: "#9CA3AF", - fontSize: 13, - lineHeight: 18, - textAlign: "center", - }, - retryButton: { - marginTop: 18, - minHeight: 40, - paddingHorizontal: 16, - borderRadius: 8, - borderWidth: 1, - borderColor: "#16A34A", - backgroundColor: "#22C55E", - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - retryText: { - color: "#ffffff", - fontSize: 14, - fontWeight: "600", - }, - toolbar: { - position: "absolute", - left: 0, - right: 0, - bottom: 0, - minHeight: 48, - paddingHorizontal: 8, - paddingVertical: 6, - backgroundColor: "#111827", - borderTopWidth: 1, - borderTopColor: "#303032", - }, - toolbarContent: { - flexDirection: "row", - alignItems: "center", - gap: 6, - }, - hiddenInput: { - position: "absolute", - width: 1, - height: 1, - opacity: 0, - color: "transparent", - }, - keyButton: { - minWidth: 44, - height: 36, - paddingHorizontal: 8, - borderRadius: 8, - borderWidth: 1, - borderColor: "#303032", - backgroundColor: "#1F2937", - alignItems: "center", - justifyContent: "center", - flexDirection: "row", - gap: 4, - }, - keyButtonText: { - color: "#ffffff", - fontSize: 12, - fontWeight: "600", - }, -}); diff --git a/app/tabs/sessions/server-stats/ServerStats.tsx b/app/tabs/sessions/server-stats/ServerStats.tsx index 3c032d3..36632a3 100644 --- a/app/tabs/sessions/server-stats/ServerStats.tsx +++ b/app/tabs/sessions/server-stats/ServerStats.tsx @@ -1,584 +1,268 @@ -import React, { - useRef, +import { + forwardRef, + useImperativeHandle, useEffect, useState, + useRef, useCallback, - forwardRef, - useImperativeHandle, } from "react"; +import { View, ScrollView, RefreshControl, Pressable } from "react-native"; +import { Zap } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; import { - View, - Text, - ScrollView, - ActivityIndicator, - RefreshControl, - TouchableOpacity, - type DimensionValue, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - Cpu, - MemoryStick, - HardDrive, - Server, -} from "lucide-react-native"; -import { getServerMetricsById, executeSnippet } from "../../../main-axios"; -import { showToast } from "../../../utils/toast"; -import type { ServerMetrics, QuickAction } from "../../../../types"; -import { useOrientation } from "@/app/utils/orientation"; + getServerMetricsById, + startMetricsPolling, + stopMetricsPolling, + registerMetricsViewer, + unregisterMetricsViewer, + sendMetricsHeartbeat, + executeSnippet, +} from "@/app/main-axios"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { toast } from "@/app/utils/toast"; +import { SessionFrame, usePolling } from "@/app/tabs/sessions/_shared"; import { - getResponsivePadding, - getColumnCount, - getTabBarHeight, -} from "@/app/utils/responsive"; + StatsConfig, + WidgetType, + DEFAULT_STATS_CONFIG, +} from "@/constants/stats-config"; import { - BACKGROUNDS, - BORDER_COLORS, - RADIUS, -} from "@/app/constants/designTokens"; + CpuWidget, + MemoryWidget, + DiskWidget, + NetworkWidget, + UptimeWidget, + SystemWidget, + ProcessesWidget, + PortsWidget, + FirewallWidget, + LoginStatsWidget, +} from "./widgets"; interface ServerStatsProps { hostConfig: { id: number; name: string; - quickActions?: QuickAction[]; + statsConfig?: string; + quickActions?: { name: string; snippetId: number }[]; }; isVisible: boolean; title?: string; onClose?: () => void; } -export type ServerStatsHandle = { +export interface ServerStatsHandle { refresh: () => void; -}; +} -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); - const [isRefreshing, setIsRefreshing] = useState(false); - const [error, setError] = useState(null); - const [executingActions, setExecutingActions] = useState>( - new Set(), - ); - const refreshIntervalRef = useRef | null>(null); +const HISTORY_LEN = 20; - const padding = getResponsivePadding(isLandscape); - const columnCount = getColumnCount(width, isLandscape, 350); - const tabBarHeight = getTabBarHeight(isLandscape); +function parseConfig(raw?: string): StatsConfig { + try { + return raw ? { ...DEFAULT_STATS_CONFIG, ...JSON.parse(raw) } : DEFAULT_STATS_CONFIG; + } catch { + return DEFAULT_STATS_CONFIG; + } +} - const fetchMetrics = useCallback( - async (showLoadingSpinner = true) => { - try { - if (showLoadingSpinner) { - setIsLoading(true); - } - setError(null); +export const ServerStats = forwardRef( + ({ hostConfig, isVisible }, ref) => { + const color = useThemeColor(); + const config = parseConfig(hostConfig.statsConfig); + const enabled = config.enabledWidgets; - 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 [metrics, setMetrics] = useState(null); + const [status, setStatus] = useState<"loading" | "ready" | "error">( + "loading", ); + const [error, setError] = useState(""); + const [refreshing, setRefreshing] = useState(false); + const startedRef = useRef(false); + const viewerSessionIdRef = useRef(null); - const handleRefresh = useCallback(() => { - setIsRefreshing(true); - fetchMetrics(false); - }, [fetchMetrics]); - - useImperativeHandle( - ref, - () => ({ - refresh: handleRefresh, - }), - [handleRefresh], + // Sparkline history for cpu/memory/disk. + const historyRef = useRef<{ cpu: number[]; memory: number[]; disk: number[] }>( + { cpu: [], memory: [], disk: [] }, ); + const [, forceTick] = useState(0); - useEffect(() => { - if (isVisible) { - fetchMetrics(); + const pushHistory = useCallback((m: ServerMetrics) => { + const h = historyRef.current; + const add = (arr: number[], v: number | null | undefined) => { + arr.push(v ?? 0); + if (arr.length > HISTORY_LEN) arr.shift(); + }; + add(h.cpu, m.cpu?.percent); + add(h.memory, m.memory?.percent); + add(h.disk, m.disk?.percent); + }, []); - refreshIntervalRef.current = setInterval(() => { - fetchMetrics(false); - }, 5000); - } else { - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); - refreshIntervalRef.current = null; + const fetchMetrics = useCallback(async () => { + try { + const data = await getServerMetricsById(hostConfig.id); + if (data === null) return; // Not ready yet — keep polling silently. + setMetrics(data); + pushHistory(data); + setStatus("ready"); + setError(""); + forceTick((t) => t + 1); + } catch (err: any) { + if (!metrics) { + setError(err?.message || "Failed to load metrics"); + setStatus("error"); } } + }, [hostConfig.id, pushHistory, metrics]); + // Start polling + register viewer when the screen becomes visible. + useEffect(() => { + if (!isVisible || startedRef.current) return; + startedRef.current = true; + (async () => { + try { + const res = await startMetricsPolling(hostConfig.id); + if (res?.requiresTOTP) { + // Stats TOTP is rare; surface as an error with retry for now. + setError("Two-factor required for this host's stats."); + setStatus("error"); + return; + } + if (res?.viewerSessionId) { + viewerSessionIdRef.current = res.viewerSessionId; + } + } catch { + // Non-fatal — metrics endpoint may already be polling. + } + // If start didn't return a viewerSessionId, register separately. + if (!viewerSessionIdRef.current) { + const reg = await registerMetricsViewer(hostConfig.id); + if (reg?.viewerSessionId) { + viewerSessionIdRef.current = reg.viewerSessionId; + } + } + fetchMetrics(); + })(); return () => { - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); + startedRef.current = false; + const vsid = viewerSessionIdRef.current; + viewerSessionIdRef.current = null; + if (vsid) { + unregisterMetricsViewer(hostConfig.id, vsid); + stopMetricsPolling(hostConfig.id, vsid); + } else { + stopMetricsPolling(hostConfig.id); } }; - }, [isVisible, fetchMetrics]); - - const cardWidth: DimensionValue = - isLandscape && columnCount > 1 - ? (`${100 / columnCount - 1}%` as DimensionValue) - : "100%"; - - const formatUptime = (seconds: number | null): string => { - if (seconds === null || seconds === undefined) return "N/A"; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible, hostConfig.id]); - 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 handleQuickAction = async (action: QuickAction) => { - setExecutingActions((prev) => new Set(prev).add(action.snippetId)); - showToast.info(`Executing ${action.name}...`); + // Poll metrics + heartbeat while visible. + usePolling( + () => { + fetchMetrics(); + if (viewerSessionIdRef.current) { + sendMetricsHeartbeat(viewerSessionIdRef.current); + } + }, + 3000, + isVisible && status !== "error", + ); - try { - const result = await executeSnippet(action.snippetId, hostConfig.id); + useImperativeHandle(ref, () => ({ refresh: fetchMetrics }), [fetchMetrics]); - if (result.success) { - showToast.success(`${action.name} completed successfully`); - } else { - showToast.error(`${action.name} failed`); + const runQuickAction = useCallback( + async (snippetId: number, name: string) => { + try { + await executeSnippet(snippetId, hostConfig.id); + toast.success(`Ran ${name}`); + } catch (e: any) { + toast.error(e?.message || `Failed to run ${name}`); } - } catch (error: any) { - showToast.error(error?.message || `Failed to execute ${action.name}`); - } finally { - setExecutingActions((prev) => { - const next = new Set(prev); - next.delete(action.snippetId); - return next; - }); - } - }; - - const renderMetricCard = ( - icon: React.ReactNode, - title: string, - value: string, - subtitle: string, - color: string, - ) => { - return ( - 1 ? 0 : 12, - width: cardWidth, - }} - > - - {icon} - - {title} - - + }, + [hostConfig.id], + ); - - - {value} - - {subtitle} - - - ); - }; + if (!isVisible) return null; - if (!isVisible) { - return null; - } + const has = (w: WidgetType) => enabled.includes(w); + const h = historyRef.current; return ( - { + setStatus("loading"); + startedRef.current = false; + fetchMetrics(); + } + : undefined + } > - {isLoading && !metrics ? ( - - - - Loading server metrics... - - - ) : error ? ( - - - - Failed to Load Metrics - - { + setRefreshing(true); + await fetchMetrics(); + setRefreshing(false); }} - > - {error} - - - - Retry - - - - ) : ( - - } - > - - - {hostConfig.name} - - - Server Statistics - - - - {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" : "#f59145", - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: RADIUS.BUTTON, - flexDirection: "row", - alignItems: "center", - gap: 8, - opacity: isExecuting ? 0.6 : 1, - }} - activeOpacity={0.7} - > - {isExecuting && ( - - )} - - {action.name} - - - ); - })} - - - )} - - 1 ? "row" : "column", - flexWrap: "wrap", - gap: 12, - }} - > - 1 ? 0 : 12, - width: cardWidth, - }} - > - - - - CPU Usage - - - - + } + > + {/* Quick actions */} + {hostConfig.quickActions && hostConfig.quickActions.length > 0 ? ( + + {hostConfig.quickActions.map((qa) => ( + runQuickAction(qa.snippetId, qa.name)} + className="flex-row items-center gap-1.5 px-2.5 py-1.5 border border-accent-brand/40 bg-accent-brand/10 active:opacity-80" > - - {typeof metrics?.cpu?.percent === "number" - ? `${metrics.cpu.percent}%` - : "N/A"} + + + {qa.name} - - {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 - - - - - )} - - - {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", - )} - - {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", - )} + + ))} - - )} - + ) : null} + + {metrics ? ( + <> + {has("cpu") ? ( + + ) : null} + {has("memory") ? ( + + ) : null} + {has("disk") ? ( + + ) : null} + {has("network") ? : null} + {has("uptime") ? : null} + {has("processes") ? : null} + {has("ports") ? : null} + {has("login_stats") ? ( + + ) : null} + {has("firewall") ? : null} + {has("system") ? : null} + + ) : null} + + ); }, ); ServerStats.displayName = "ServerStats"; - -export default ServerStats; diff --git a/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx b/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx index 18da3c2..d2c742a 100644 --- a/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx @@ -1,131 +1,39 @@ -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 { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard, Meter } from "./WidgetCard"; -interface WidgetProps { - metrics: ServerMetrics | null; - isLoading?: boolean; -} - -export const CpuWidget: React.FC = ({ metrics, isLoading }) => { - const cpuPercent = metrics?.cpu?.percent ?? null; - const cores = metrics?.cpu?.cores ?? null; - const load = metrics?.cpu?.load ?? null; +export function CpuWidget({ + metrics, + history, +}: { + metrics: ServerMetrics; + history?: number[]; +}) { + const color = useThemeColor(); + const cpu = metrics.cpu; + const percent = Number(cpu?.percent ?? 0); return ( - - - - CPU Usage - - - - - {cpuPercent !== null ? `${cpuPercent.toFixed(1)}%` : "N/A"} + } + title="CPU" + trailing={ + + {percent.toFixed(0)}% - - {cores !== null ? `${cores} cores` : "N/A"} + } + > + + {cpu?.cores != null ? ( + + {cpu.cores} cores + {cpu.load + ? ` · load ${cpu.load.map((l) => Number(l).toFixed(2)).join(", ")}` + : ""} - - - {load && ( - - - {load[0].toFixed(2)} - 1m - - - {load[1].toFixed(2)} - 5m - - - {load[2].toFixed(2)} - 15m - - - )} - - {isLoading && ( - - - - )} - + ) : null} + ); -}; - -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 index e663730..b2325a1 100644 --- a/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx @@ -1,98 +1,37 @@ -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 { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard, Meter } from "./WidgetCard"; -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; +export function DiskWidget({ + metrics, + history, +}: { + metrics: ServerMetrics; + history?: number[]; +}) { + const color = useThemeColor(); + const disk = metrics.disk; + const percent = Number(disk?.percent ?? 0); return ( - - - - Disk Usage - - - - - {diskPercent !== null ? `${diskPercent.toFixed(1)}%` : "N/A"} + } + title="Disk" + trailing={ + + {percent.toFixed(0)}% - - {usedHuman !== null && totalHuman !== null - ? `${usedHuman} / ${totalHuman}` - : "N/A"} + } + > + + {disk?.usedHuman && disk?.totalHuman ? ( + + {disk.usedHuman} / {disk.totalHuman} + {disk.availableHuman ? ` · ${disk.availableHuman} free` : ""} - - - {isLoading && ( - - - - )} - + ) : null} + ); -}; - -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/FirewallWidget.tsx b/app/tabs/sessions/server-stats/widgets/FirewallWidget.tsx new file mode 100644 index 0000000..9e4679d --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/FirewallWidget.tsx @@ -0,0 +1,38 @@ +import { View } from "react-native"; +import { Shield } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text, Badge } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard } from "./WidgetCard"; + +export function FirewallWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const fw = metrics.firewall; + const status = fw?.status ?? "unknown"; + const ruleCount = + fw?.chains?.reduce((sum, c) => sum + (c.rules?.length ?? 0), 0) ?? 0; + + return ( + } + title="Firewall" + trailing={ + + {status} + + } + > + + {fw?.type && fw.type !== "none" ? ( + + {fw.type} · {ruleCount} rules + + ) : ( + + No firewall detected + + )} + + + ); +} 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..974f25c --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/LoginStatsWidget.tsx @@ -0,0 +1,95 @@ +import { View } from "react-native"; +import { Users } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { WidgetCard } from "./WidgetCard"; + +export function LoginStatsWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const login = metrics.loginStats; + + const recent = login?.recentLogins?.slice(0, 5) ?? []; + const failed = login?.failedLogins?.slice(0, 5) ?? []; + const total = login?.totalLogins ?? 0; + const uniqueIPs = login?.uniqueIPs ?? 0; + + const isEmpty = recent.length === 0 && failed.length === 0; + + return ( + } + title="Login Activity" + trailing={ + total > 0 ? ( + {total} total + ) : undefined + } + > + {isEmpty ? ( + + No login activity recorded + + ) : ( + + + {total} logins · {uniqueIPs} unique IP{uniqueIPs !== 1 ? "s" : ""} + + + {recent.length > 0 ? ( + + + Recent + + {recent.map((e, i) => ( + + + {e.user} + + + {[e.from, e.time, e.type].filter(Boolean).join(" · ")} + + + ))} + + ) : null} + + {failed.length > 0 ? ( + + + Failed + + {failed.map((e, i) => ( + + + {e.user} + + + {[e.from, e.time, e.type].filter(Boolean).join(" · ")} + + + ))} + + ) : null} + + )} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx index 58ed401..23d58b4 100644 --- a/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx +++ b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx @@ -1,98 +1,36 @@ -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 { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard, Meter } from "./WidgetCard"; -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; +export function MemoryWidget({ + metrics, + history, +}: { + metrics: ServerMetrics; + history?: number[]; +}) { + const color = useThemeColor(); + const mem = metrics.memory; + const percent = Number(mem?.percent ?? 0); return ( - - - - Memory Usage - - - - - {memoryPercent !== null ? `${memoryPercent.toFixed(1)}%` : "N/A"} + } + title="Memory" + trailing={ + + {percent.toFixed(0)}% - - {usedGiB !== null && totalGiB !== null - ? `${usedGiB.toFixed(2)} / ${totalGiB.toFixed(2)} GiB` - : "N/A"} + } + > + + {mem?.usedGiB != null && mem?.totalGiB != null ? ( + + {Number(mem.usedGiB).toFixed(1)} / {Number(mem.totalGiB).toFixed(1)} GiB - - - {isLoading && ( - - - - )} - + ) : null} + ); -}; - -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..e7a8da2 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/NetworkWidget.tsx @@ -0,0 +1,61 @@ +import { View } from "react-native"; +import { Network } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard } from "./WidgetCard"; + +export function NetworkWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const interfaces = metrics.network?.interfaces ?? []; + + return ( + } + title="Network" + > + {interfaces.length === 0 ? ( + + No interfaces + + ) : ( + + {interfaces.map((iface) => { + const up = (iface.state || "").toUpperCase() === "UP"; + return ( + + + + + {iface.name} + {iface.ip ? ( + + {" "} + {iface.ip} + + ) : null} + + + {iface.rx != null || iface.tx != null || iface.rxBytes != null || iface.txBytes != null ? ( + + ↓ {iface.rx ?? iface.rxBytes ?? "—"} ↑ {iface.tx ?? iface.txBytes ?? "—"} + + ) : null} + + ); + })} + + )} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/PortsWidget.tsx b/app/tabs/sessions/server-stats/widgets/PortsWidget.tsx new file mode 100644 index 0000000..611a520 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/PortsWidget.tsx @@ -0,0 +1,51 @@ +import { View } from "react-native"; +import { Plug } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { WidgetCard } from "./WidgetCard"; + +export function PortsWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const ports = metrics.ports?.ports ?? []; + + return ( + } + title="Listening Ports" + trailing={ + ports.length ? ( + {ports.length} + ) : undefined + } + > + {ports.length === 0 ? ( + + No listening ports + + ) : ( + + {ports.slice(0, 20).map((p, i) => ( + + + {p.protocol}/{p.localPort} + + + {p.process || p.pid || "—"} + {p.localAddress ? ` ${p.localAddress}` : ""} + + + ))} + + )} + + ); +} 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..bbedc0e --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/ProcessesWidget.tsx @@ -0,0 +1,66 @@ +import { View } from "react-native"; +import { Activity } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { MONO_FONT } from "@/app/constants/fonts"; +import { WidgetCard } from "./WidgetCard"; + +export function ProcessesWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const proc = metrics.processes; + const top = (proc?.top ?? []).slice(0, 8); + + return ( + } + title="Processes" + trailing={ + proc?.total != null ? ( + + {proc.running ?? 0}/{proc.total} + + ) : undefined + } + > + {top.length === 0 ? ( + + No process data + + ) : ( + + + CPU% + MEM% + + COMMAND + + + {top.map((p, i) => ( + + + {p.cpu.toFixed(1)} + + + {p.mem.toFixed(1)} + + + {p.command} + + + ))} + + )} + + ); +} 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..11503e3 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/SystemWidget.tsx @@ -0,0 +1,42 @@ +import { View } from "react-native"; +import { Server } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard } from "./WidgetCard"; + +function Row({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( + + {label} + + {value} + + + ); +} + +export function SystemWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const sys = metrics.system; + + return ( + } + title="System" + > + + + + {!sys?.hostname && !sys?.os && !sys?.kernel ? ( + + No system info + + ) : null} + + ); +} 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..cef8101 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/UptimeWidget.tsx @@ -0,0 +1,35 @@ +import { Clock } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; +import { WidgetCard } from "./WidgetCard"; + +function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const parts: string[] = []; + if (d) parts.push(`${d}d`); + if (h) parts.push(`${h}h`); + if (m || parts.length === 0) parts.push(`${m}m`); + return parts.join(" "); +} + +export function UptimeWidget({ metrics }: { metrics: ServerMetrics }) { + const color = useThemeColor(); + const uptime = metrics.uptime; + const text = + uptime?.formatted || + (uptime?.seconds != null ? formatUptime(uptime.seconds) : "—"); + + return ( + } + title="Uptime" + > + + {text} + + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/WidgetCard.tsx b/app/tabs/sessions/server-stats/widgets/WidgetCard.tsx new file mode 100644 index 0000000..ce48ec2 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/WidgetCard.tsx @@ -0,0 +1,95 @@ +import { View } from "react-native"; +import { Text } from "@/app/components/ui"; +import { useThemeColor } from "@/app/contexts/ThemeContext"; + +/** Shared card chrome for every stats widget: icon + title + optional trailing. */ +export function WidgetCard({ + icon, + title, + trailing, + children, +}: { + icon: React.ReactNode; + title: string; + trailing?: React.ReactNode; + children?: React.ReactNode; +}) { + return ( + + + {icon} + + {title} + + {trailing ? {trailing} : null} + + {children} + + ); +} + +/** A labeled usage bar (CPU / memory / disk). */ +export function Meter({ + percent, + history, +}: { + percent: number; + history?: number[]; +}) { + const color = useThemeColor(); + const pct = Math.max(0, Math.min(100, percent)); + const barColor = + pct >= 90 + ? (color("destructive") ?? "#ef4444") + : pct >= 70 + ? "#f59e0b" + : (color("accent-brand") ?? "#f59145"); + return ( + + {history && history.length > 1 ? ( + + ) : null} + + + + + ); +} + +/** + * Tiny sparkline rendered with stacked Views (no SVG dependency). Each sample + * is a thin vertical bar scaled to the series max — good enough for a 20-point + * trend strip and avoids pulling in react-native-svg. + */ +export function Sparkline({ + data, + stroke, + height = 28, +}: { + data: number[]; + stroke: string; + height?: number; +}) { + const max = Math.max(1, ...data); + return ( + + {data.map((v, i) => ( + + ))} + + ); +} diff --git a/app/tabs/sessions/server-stats/widgets/index.ts b/app/tabs/sessions/server-stats/widgets/index.ts index 3707803..39988ae 100644 --- a/app/tabs/sessions/server-stats/widgets/index.ts +++ b/app/tabs/sessions/server-stats/widgets/index.ts @@ -1,3 +1,11 @@ export { CpuWidget } from "./CpuWidget"; export { MemoryWidget } from "./MemoryWidget"; export { DiskWidget } from "./DiskWidget"; +export { NetworkWidget } from "./NetworkWidget"; +export { UptimeWidget } from "./UptimeWidget"; +export { SystemWidget } from "./SystemWidget"; +export { ProcessesWidget } from "./ProcessesWidget"; +export { PortsWidget } from "./PortsWidget"; +export { FirewallWidget } from "./FirewallWidget"; +export { LoginStatsWidget } from "./LoginStatsWidget"; +export { WidgetCard, Meter, Sparkline } from "./WidgetCard"; diff --git a/app/tabs/sessions/terminal/NativeWebSocketManager.ts b/app/tabs/sessions/terminal/NativeWebSocketManager.ts index 19f6f71..b88a5f5 100644 --- a/app/tabs/sessions/terminal/NativeWebSocketManager.ts +++ b/app/tabs/sessions/terminal/NativeWebSocketManager.ts @@ -51,11 +51,15 @@ export interface NativeWSConfig { scenario: "new" | "changed", data: HostKeyData, ) => void; + onPassphraseRequired?: () => void; + onWarpgateAuthRequired?: (url: string, securityKey: string) => void; onPostConnectionSetup: () => void; onDisconnected: (hostName: string) => void; onConnectionFailed: (message: string) => void; /** Fired when the backend session id is created/attached/cleared. */ onSessionIdChange?: (sessionId: string | null) => void; + /** Fired for each `connection_log` WS message from the server. */ + onConnectionLog?: (entry: { level?: string; stage?: string; message: string }) => void; } export class NativeWebSocketManager { @@ -228,6 +232,32 @@ export class NativeWebSocketManager { } } + sendWarpgateContinue(): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(JSON.stringify({ type: "warpgate_auth_continue", data: {} })); + } catch (_) {} + } + } + + sendPassphraseResponse(passphrase: string): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send( + JSON.stringify({ + type: "reconnect_with_credentials", + data: { + keyPassword: passphrase, + hostConfig: { ...this.config.hostConfig, keyPassword: passphrase }, + cols: this.cols, + rows: this.rows, + }, + }), + ); + } catch (_) {} + } + } + notifyBackgrounded(): void { this.isAppInBackground = true; this.backgroundTime = Date.now(); @@ -413,6 +443,21 @@ export class NativeWebSocketManager { msg.data as HostKeyData, ); } + } else if (msg.type === "passphrase_required") { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + this.config.onPassphraseRequired?.(); + } else if (msg.type === "warpgate_auth_required") { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + this.config.onWarpgateAuthRequired?.( + (msg.url as string) || "", + (msg.securityKey as string) || "", + ); } else if (msg.type === "error") { const message = (msg.message as string) || "Unknown error"; if (this.isUnrecoverableError(message)) { @@ -463,6 +508,10 @@ export class NativeWebSocketManager { this.setServerSessionId(null); this.shouldNotReconnect = true; this.config.onDisconnected(this.config.hostConfig.name); + } else if (msg.type === "connection_log") { + if (msg.data) { + this.config.onConnectionLog?.(msg.data as { level?: string; stage?: string; message: string }); + } } } catch (_) { this.config.onData(event.data as string); diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 458827d..2f9911f 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -19,11 +19,13 @@ import { ChevronDown } from "lucide-react-native"; import { logActivity, getSnippets } from "../../../main-axios"; import { showToast } from "../../../utils/toast"; import { useTerminalCustomization } from "../../../contexts/TerminalCustomizationContext"; -import { BACKGROUNDS, BORDER_COLORS } from "../../../constants/designTokens"; +import { BACKGROUNDS, ACCENT, TEXT_COLORS } from "../../../constants/designTokens"; import { TOTPDialog, SSHAuthDialog, HostKeyVerificationDialog, + PassphraseDialog, + WarpgateDialog, } from "@/app/tabs/dialogs"; import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes"; import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; @@ -33,6 +35,7 @@ import { type TerminalHostConfig, type HostKeyData, } from "./NativeWebSocketManager"; +import { useConnectionLog, ConnectionLog } from "../_shared/useConnectionLog"; interface TerminalProps { hostConfig: { @@ -98,6 +101,7 @@ const TerminalComponent = forwardRef( ); const { config } = useTerminalCustomization(); + const log = useConnectionLog(); const [webViewKey, setWebViewKey] = useState(0); const [screenDimensions, setScreenDimensions] = useState( Dimensions.get("window"), @@ -114,7 +118,7 @@ const TerminalComponent = forwardRef( const [hasReceivedData, setHasReceivedData] = useState(false); const [htmlContent, setHtmlContent] = useState(""); const [terminalBackgroundColor, setTerminalBackgroundColor] = - useState("#09090b"); + useState(BACKGROUNDS.DARKEST); const [totpRequired, setTotpRequired] = useState(false); const [totpPrompt, setTotpPrompt] = useState(""); @@ -123,6 +127,11 @@ const TerminalComponent = forwardRef( const [authDialogReason, setAuthDialogReason] = useState< "no_keyboard" | "auth_failed" | "timeout" >("auth_failed"); + const [passphraseRequired, setPassphraseRequired] = useState(false); + const [warpgateAuth, setWarpgateAuth] = useState<{ + url: string; + securityKey: string; + } | null>(null); const [isSelecting, setIsSelecting] = useState(false); const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false); @@ -249,7 +258,6 @@ const TerminalComponent = forwardRef( Terminal - + +