diff --git a/README.md b/README.md index 4369fbe..f149dd7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ If you would like, you can support the project here!\ # Overview -Termix Mobile is the official mobile companion app for Termix, providing remote SSH terminal control of your servers. You can connect to your existing Termix server configuration and manage all your SSH hosts with terminal capabilities optimized for mobile devices. +Full remote SSH control of your servers with Termix, the ultimate SSH server management tool. It connects to your existing Termix server to provide you with SSH server access. # Planned Features @@ -27,9 +27,11 @@ See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned fe # Features -- **SSH Terminal** - SSH terminal with multi-session support -- **Advanced Keyboard - Switch between two keyboard modes: the system keyboard and a custom terminal keyboard that is optimized for terminal use. The custom keyboard is completely configurable to your preferences. -- **Server Configuration** - Easily connect to your existing Termix server via IP/Domain. It has support for reverse proxy access login pages, logging in with OIDC, and, of course, regular username/password logins. +- **SSH Terminal** - SSH terminal with multi-session support. Switch between two keyboard modes: the system keyboard and a custom terminal keyboard that is optimized for terminal use. The custom keyboard is completely configurable to your preferences. +- **SSH File Manager** - View, edit, modify, and move files and folders via SSH. +- **SSH Server Stats** - Get information on a servers status such as CPU, RAM, HDD, etc. +- **SSH Tunnels** - Start, stop, and manage SSH tunnels. +- **Server Configuration** - Easily connect to your existing Termix server via IP/domain. It has support for reverse proxy access login pages, logging in with OIDC, and, of course, regular username/password logins. # Installation diff --git a/app.json b/app.json index ab9fe07..d5713bc 100644 --- a/app.json +++ b/app.json @@ -2,8 +2,8 @@ "expo": { "name": "Termix", "slug": "termix", - "version": "1.1.0", - "orientation": "portrait", + "version": "1.2.0", + "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "termix-mobile", "githubUrl": "https://github.com/Termix-SSH/Mobile", @@ -57,12 +57,13 @@ "usesCleartextTraffic": true }, "ios": { - "newArchEnabled": true + "newArchEnabled": true, + "deploymentTarget": "15.1" } } ], - "./plugins/withNetworkSecurityConfig.js", "./plugins/withIOSNetworkSecurity.js", + "./plugins/withNetworkSecurityConfig.js", "expo-dev-client" ], "experiments": { diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index de1bcc7..0f607b2 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -2,16 +2,21 @@ import { Tabs, usePathname } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTerminalSessions } from "../contexts/TerminalSessionsContext"; +import { useOrientation } from "../utils/orientation"; +import { getTabBarHeight } from "../utils/responsive"; export default function TabLayout() { const insets = useSafeAreaInsets(); const { sessions } = useTerminalSessions(); const pathname = usePathname(); + const { isLandscape } = useOrientation(); const isSessionsTab = pathname === "/sessions"; const hasActiveSessions = sessions.length > 0; const shouldHideMainTabBar = isSessionsTab && hasActiveSessions; + const tabBarHeight = getTabBarHeight(isLandscape); + return ( ; diff --git a/app/(tabs)/sessions.tsx b/app/(tabs)/sessions.tsx index e6f8174..1f964ca 100644 --- a/app/(tabs)/sessions.tsx +++ b/app/(tabs)/sessions.tsx @@ -1,4 +1,4 @@ -import Sessions from "@/app/Tabs/Sessions/Sessions"; +import Sessions from "@/app/tabs/sessions/Sessions"; export default function SessionsScreen() { return ; diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 48d7a22..2a6ac06 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,4 +1,4 @@ -import Settings from "@/app/Tabs/Settings/Settings"; +import Settings from "@/app/tabs/settings/Settings"; export default function SettingsScreen() { return ; diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 01ed797..1fc7a5d 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -4,7 +4,9 @@ import React, { useState, ReactNode, useEffect, + useRef, } from "react"; +import { AppState, AppStateStatus } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { getVersionInfo, @@ -176,6 +178,53 @@ export const AppProvider: React.FC = ({ children }) => { }); }, []); + const lastValidationTimeRef = useRef(0); + const validationInProgressRef = useRef(false); + + useEffect(() => { + const handleAppStateChange = async (nextAppState: AppStateStatus) => { + if ( + nextAppState === "active" && + isAuthenticated && + !validationInProgressRef.current + ) { + const now = Date.now(); + const timeSinceLastValidation = now - lastValidationTimeRef.current; + + if (timeSinceLastValidation < 2000) { + return; + } + + validationInProgressRef.current = true; + lastValidationTimeRef.current = now; + + try { + const { getUserInfo } = await import("./main-axios"); + const userInfo = await getUserInfo(); + + if ( + !userInfo || + !userInfo.username || + userInfo.data_unlocked === false + ) { + } + } catch (error) { + } finally { + validationInProgressRef.current = false; + } + } + }; + + const subscription = AppState.addEventListener( + "change", + handleAppStateChange, + ); + + return () => { + subscription.remove(); + }; + }, [isAuthenticated]); + return ( void; - style?: any; - textStyle?: any; - isActive?: boolean; - isModifier?: boolean; - keySize?: KeySize; - hapticFeedback?: boolean; - onLongPress?: () => void; -} - -export default function KeyboardKey({ - label, - onPress, - style = {}, - textStyle = {}, - isActive = false, - isModifier = false, - keySize = "medium", - hapticFeedback = false, - onLongPress, -}: KeyboardKeyProps) { - const handlePress = () => { - if (hapticFeedback) { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - onPress(); - }; - - const handleLongPress = () => { - if (hapticFeedback) { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - } - if (onLongPress) { - onLongPress(); - } - }; - - const sizeStyles = getSizeStyles(keySize); - - return ( - - {label} - - ); -} - -function getSizeStyles(size: KeySize) { - switch (size) { - case "small": - return { - button: { - paddingHorizontal: 6, - paddingVertical: 6, - minWidth: 32, - minHeight: 32, - }, - text: { - fontSize: 11, - }, - }; - case "large": - return { - button: { - paddingHorizontal: 10, - paddingVertical: 10, - minWidth: 42, - minHeight: 42, - }, - text: { - fontSize: 14, - }, - }; - case "medium": - default: - return { - button: { - paddingHorizontal: 8, - paddingVertical: 8, - minWidth: 36, - minHeight: 36, - }, - text: { - fontSize: 12, - }, - }; - } -} - -const styles = StyleSheet.create({ - keyButton: { - backgroundColor: "#2a2a2a", - borderWidth: 1, - borderColor: "#404040", - borderRadius: 6, - alignItems: "center", - justifyContent: "center", - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 2, - elevation: 1, - }, - modifierKey: { - backgroundColor: "#2a2a2a", - borderColor: "#404040", - }, - activeKey: { - backgroundColor: "#4a4a4a", - borderColor: "#606060", - shadowOpacity: 0.3, - }, - keyText: { - color: "#ffffff", - fontWeight: "500", - textAlign: "center", - }, -}); diff --git a/app/Tabs/Sessions/Navigation/TabBar.tsx b/app/Tabs/Sessions/Navigation/TabBar.tsx deleted file mode 100644 index 1e4fa2c..0000000 --- a/app/Tabs/Sessions/Navigation/TabBar.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import React from "react"; -import { - View, - Text, - TouchableOpacity, - ScrollView, - TextInput, - Keyboard, -} from "react-native"; -import { - X, - ArrowLeft, - Plus, - Minus, - ChevronDown, - ChevronUp, -} from "lucide-react-native"; -import { TerminalSession } from "@/app/contexts/TerminalSessionsContext"; -import { useRouter } from "expo-router"; -import { useKeyboard } from "@/app/contexts/KeyboardContext"; - -interface TabBarProps { - sessions: TerminalSession[]; - activeSessionId: string | null; - onTabPress: (sessionId: string) => void; - onTabClose: (sessionId: string) => void; - onAddSession?: () => void; - onToggleKeyboard?: () => void; - isCustomKeyboardVisible: boolean; - hiddenInputRef: React.RefObject; - onHideKeyboard?: () => void; - onShowKeyboard?: () => void; - keyboardIntentionallyHiddenRef: React.MutableRefObject; -} - -export default function TabBar({ - sessions, - activeSessionId, - onTabPress, - onTabClose, - onAddSession, - onToggleKeyboard, - isCustomKeyboardVisible, - hiddenInputRef, - onHideKeyboard, - onShowKeyboard, - keyboardIntentionallyHiddenRef, -}: TabBarProps) { - const router = useRouter(); - const { isKeyboardVisible } = useKeyboard(); - - const handleToggleSystemKeyboard = () => { - if (keyboardIntentionallyHiddenRef.current) { - onShowKeyboard?.(); - setTimeout(() => { - hiddenInputRef.current?.focus(); - }, 50); - } else { - onHideKeyboard?.(); - Keyboard.dismiss(); - } - }; - - if (sessions.length === 0) { - return null; - } - - return ( - - - router.navigate("/hosts" as any)} - focusable={false} - className="items-center justify-center rounded-md" - activeOpacity={0.7} - style={{ - width: 44, - height: 44, - borderWidth: 2, - borderColor: "#303032", - backgroundColor: "#2a2a2a", - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - marginRight: 8, - }} - > - - - - - - {sessions.map((session) => { - const isActive = session.id === activeSessionId; - - return ( - onTabPress(session.id)} - focusable={false} - className="flex-row items-center rounded-md" - style={{ - borderWidth: 2, - borderColor: isActive ? "#22c55e" : "#303032", - backgroundColor: isActive ? "#1a1a1a" : "#1a1a1a", - shadowColor: isActive ? "#22c55e" : "transparent", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: isActive ? 0.2 : 0, - shadowRadius: 4, - elevation: isActive ? 3 : 0, - minWidth: 120, - height: 44, - }} - > - - - {session.title} - - - - { - e.stopPropagation(); - onTabClose(session.id); - }} - focusable={false} - className="items-center justify-center" - activeOpacity={0.7} - style={{ - width: 36, - height: 44, - borderLeftWidth: 2, - borderLeftColor: isActive ? "#22c55e" : "#303032", - }} - > - - - - ); - })} - - - - {!isCustomKeyboardVisible && ( - - {keyboardIntentionallyHiddenRef.current ? ( - - ) : ( - - )} - - )} - - onToggleKeyboard?.()} - focusable={false} - className="items-center justify-center rounded-md" - activeOpacity={0.7} - style={{ - width: 44, - height: 44, - borderWidth: 2, - borderColor: "#303032", - backgroundColor: "#2a2a2a", - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - marginLeft: 8, - }} - > - {isCustomKeyboardVisible ? ( - - ) : ( - - )} - - - - ); -} diff --git a/app/Tabs/Sessions/Sessions.tsx b/app/Tabs/Sessions/Sessions.tsx deleted file mode 100644 index b38f820..0000000 --- a/app/Tabs/Sessions/Sessions.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; -import { - View, - Text, - ScrollView, - TouchableOpacity, - StyleSheet, - Keyboard, - KeyboardAvoidingView, - Platform, - TextInput, - TouchableWithoutFeedback, - Pressable, - Dimensions, - BackHandler, - AppState, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useFocusEffect } from "@react-navigation/native"; -import { useRouter } from "expo-router"; -import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; -import { useKeyboard } from "@/app/contexts/KeyboardContext"; -import { Terminal, TerminalHandle } from "@/app/Tabs/Sessions/Terminal"; -import TabBar from "@/app/Tabs/Sessions/Navigation/TabBar"; -import CustomKeyboard from "@/app/Tabs/Sessions/CustomKeyboard"; -import KeyboardBar from "@/app/Tabs/Sessions/KeyboardBar"; -import { ArrowLeft } from "lucide-react-native"; - -export default function Sessions() { - const insets = useSafeAreaInsets(); - const router = useRouter(); - const { - sessions, - activeSessionId, - setActiveSession, - removeSession, - isCustomKeyboardVisible, - toggleCustomKeyboard, - lastKeyboardHeight, - setLastKeyboardHeight, - keyboardIntentionallyHiddenRef, - setKeyboardIntentionallyHidden, - } = useTerminalSessions(); - const { keyboardHeight, isKeyboardVisible } = useKeyboard(); - const hiddenInputRef = useRef(null); - const terminalRefs = useRef>>( - {}, - ); - const [activeModifiers, setActiveModifiers] = useState({ - ctrl: false, - alt: false, - }); - const [screenDimensions, setScreenDimensions] = useState( - Dimensions.get("window"), - ); - const [keyboardType, setKeyboardType] = useState("default"); - - useEffect(() => { - const map: Record> = { - ...terminalRefs.current, - }; - sessions.forEach((s) => { - if (!map[s.id]) { - map[s.id] = - React.createRef() as React.RefObject; - } - }); - Object.keys(map).forEach((id) => { - if (!sessions.find((s) => s.id === id)) { - delete map[id]; - } - }); - terminalRefs.current = map; - }, [sessions]); - - useFocusEffect( - React.useCallback(() => { - if ( - sessions.length > 0 && - !isCustomKeyboardVisible && - !keyboardIntentionallyHiddenRef.current - ) { - setTimeout(() => { - hiddenInputRef.current?.focus(); - }, 1000); - } - - return () => {}; - }, [ - sessions.length, - isCustomKeyboardVisible, - keyboardIntentionallyHiddenRef, - ]), - ); - - useEffect(() => { - const subscription = AppState.addEventListener("change", (nextAppState) => { - if (nextAppState === "active") { - if ( - sessions.length > 0 && - !isCustomKeyboardVisible && - !keyboardIntentionallyHiddenRef.current - ) { - setTimeout(() => { - setKeyboardType("email-address"); - setTimeout(() => { - setKeyboardType("default"); - hiddenInputRef.current?.focus(); - }, 100); - }, 250); - } - } - }); - - return () => { - subscription.remove(); - }; - }, [sessions.length, isCustomKeyboardVisible, activeSessionId]); - - useEffect(() => { - if (Platform.OS === "android" && sessions.length > 0) { - const backHandler = BackHandler.addEventListener( - "hardwareBackPress", - () => { - if (isKeyboardVisible) { - setKeyboardIntentionallyHidden(true); - Keyboard.dismiss(); - return true; - } - return true; - }, - ); - - return () => { - backHandler.remove(); - }; - } - }, [sessions.length, isKeyboardVisible]); - - useEffect(() => { - if ( - sessions.length > 0 && - !isKeyboardVisible && - !isCustomKeyboardVisible && - !keyboardIntentionallyHiddenRef.current - ) { - const timeoutId = setTimeout(() => { - hiddenInputRef.current?.focus(); - }, 3000); - return () => clearTimeout(timeoutId); - } - }, [ - isKeyboardVisible, - sessions.length, - isCustomKeyboardVisible, - keyboardIntentionallyHiddenRef, - ]); - - useEffect(() => { - const subscription = Dimensions.addEventListener("change", ({ window }) => { - setScreenDimensions(window); - - setTimeout(() => { - const activeRef = activeSessionId - ? terminalRefs.current[activeSessionId] - : null; - activeRef?.current?.fit(); - }, 300); - }); - - return () => subscription?.remove(); - }, [activeSessionId]); - - useEffect(() => { - if (keyboardHeight > 0) { - setLastKeyboardHeight(keyboardHeight); - } - }, [keyboardHeight, setLastKeyboardHeight]); - - useEffect(() => { - const activeRef = activeSessionId - ? terminalRefs.current[activeSessionId] - : null; - if (activeRef && activeRef.current) { - setTimeout(() => { - activeRef.current?.fit(); - }, 0); - } - }, [keyboardHeight, activeSessionId, screenDimensions]); - - useFocusEffect( - React.useCallback(() => { - if ( - sessions.length > 0 && - !isCustomKeyboardVisible && - !keyboardIntentionallyHiddenRef.current - ) { - setTimeout(() => { - hiddenInputRef.current?.focus(); - const activeRef = activeSessionId - ? terminalRefs.current[activeSessionId] - : null; - activeRef?.current?.fit(); - }, 0); - } - }, [ - sessions.length, - activeSessionId, - isCustomKeyboardVisible, - keyboardIntentionallyHiddenRef, - ]), - ); - - const handleTabPress = (sessionId: string) => { - setKeyboardIntentionallyHidden(false); - hiddenInputRef.current?.focus(); - requestAnimationFrame(() => { - setActiveSession(sessionId); - setTimeout(() => hiddenInputRef.current?.focus(), 0); - }); - }; - - const handleTabClose = (sessionId: string) => { - hiddenInputRef.current?.focus(); - requestAnimationFrame(() => { - removeSession(sessionId); - setTimeout(() => hiddenInputRef.current?.focus(), 0); - }); - }; - - const handleAddSession = () => { - router.navigate("/hosts" as any); - }; - - const handleToggleKeyboard = () => { - if (isCustomKeyboardVisible) { - Keyboard.dismiss(); - setTimeout(() => { - toggleCustomKeyboard(); - }, 100); - } else { - setKeyboardIntentionallyHidden(false); - toggleCustomKeyboard(); - } - }; - - const handleModifierChange = useCallback( - (modifiers: { ctrl: boolean; alt: boolean }) => { - setActiveModifiers(modifiers); - }, - [], - ); - - const activeSession = sessions.find( - (session) => session.id === activeSessionId, - ); - - return ( - - 0 - ? keyboardHeight + 115 - : lastKeyboardHeight > 0 - ? lastKeyboardHeight + 115 - : 115, - }} - > - {sessions.map((session) => ( - handleTabClose(session.id)} - /> - ))} - - - {sessions.length === 0 && ( - - - - No Active Terminal Sessions - - - Connect to a host from the Hosts tab to start a terminal session - - { - handleAddSession(); - }} - > - - Go to Hosts - - - - - )} - - {sessions.length > 0 && ( - 0 - ? keyboardHeight + 115 - : lastKeyboardHeight > 0 - ? lastKeyboardHeight + 115 - : 115, - backgroundColor: "#09090b", - zIndex: 999, - }} - /> - )} - - {sessions.length > 0 && ( - 0 - ? keyboardHeight - : 0, - left: 0, - right: 0, - height: keyboardIntentionallyHiddenRef.current ? 66 : 50, - zIndex: 1003, - }} - > - () - } - isVisible={true} - onModifierChange={handleModifierChange} - isKeyboardIntentionallyHidden={ - keyboardIntentionallyHiddenRef.current - } - /> - - )} - - 0 - ? keyboardHeight + 50 - : 50, - left: 0, - right: 0, - height: 60, - zIndex: 1004, - }} - > - setKeyboardIntentionallyHidden(true)} - onShowKeyboard={() => setKeyboardIntentionallyHidden(false)} - keyboardIntentionallyHiddenRef={keyboardIntentionallyHiddenRef} - /> - - - {sessions.length > 0 && isCustomKeyboardVisible && ( - - () - } - isVisible={isCustomKeyboardVisible} - keyboardHeight={lastKeyboardHeight} - isKeyboardIntentionallyHidden={ - keyboardIntentionallyHiddenRef.current - } - /> - - )} - - {sessions.length > 0 && !isCustomKeyboardVisible && ( - 0 ? keyboardHeight : 0, - left: 0, - width: 1, - height: 1, - opacity: 0, - color: "transparent", - backgroundColor: "transparent", - zIndex: 1001, - }} - pointerEvents="none" - autoFocus={false} - showSoftInputOnFocus={true} - keyboardType={keyboardType} - returnKeyType="default" - blurOnSubmit={false} - editable={true} - autoCorrect={false} - autoCapitalize="none" - spellCheck={false} - textContentType="none" - caretHidden - contextMenuHidden - underlineColorAndroid="transparent" - multiline - onChangeText={(text) => {}} - onKeyPress={({ nativeEvent }) => { - const key = nativeEvent.key; - const activeRef = activeSessionId - ? terminalRefs.current[activeSessionId] - : null; - if (activeRef && activeRef.current) { - let finalKey = key; - - if (activeModifiers.ctrl) { - switch (key.toLowerCase()) { - case "c": - finalKey = "\x03"; - break; - case "d": - finalKey = "\x04"; - break; - case "z": - finalKey = "\x1a"; - break; - case "l": - finalKey = "\x0c"; - break; - case "a": - finalKey = "\x01"; - break; - case "e": - finalKey = "\x05"; - break; - case "k": - finalKey = "\x0b"; - break; - case "u": - finalKey = "\x15"; - break; - case "w": - finalKey = "\x17"; - break; - default: - finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); - } - } else if (activeModifiers.alt) { - finalKey = `\x1b${key}`; - } - - if (key === "Enter") { - activeRef.current.sendInput("\r"); - } else if (key === "Backspace") { - activeRef.current.sendInput("\b"); - } else if (key.length === 1) { - activeRef.current.sendInput(finalKey); - } - } - }} - onFocus={() => { - setKeyboardIntentionallyHidden(false); - }} - /> - )} - - ); -} diff --git a/app/Tabs/Sessions/Terminal.tsx b/app/Tabs/Sessions/Terminal.tsx deleted file mode 100644 index 110feb1..0000000 --- a/app/Tabs/Sessions/Terminal.tsx +++ /dev/null @@ -1,749 +0,0 @@ -import React, { - useRef, - useEffect, - useState, - useCallback, - forwardRef, - useImperativeHandle, -} from "react"; -import { - View, - Text, - ActivityIndicator, - Dimensions, - Platform, - TouchableWithoutFeedback, - Keyboard, - TextInput, -} from "react-native"; -import { WebView } from "react-native-webview"; -import { getCurrentServerUrl, getCookie } from "../../main-axios"; -import { showToast } from "../../utils/toast"; -import { useTerminalCustomization } from "../../contexts/TerminalCustomizationContext"; - -interface TerminalProps { - hostConfig: { - id: number; - name: string; - ip: string; - port: number; - username: string; - authType: "password" | "key" | "credential"; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - credentialId?: number; - }; - isVisible: boolean; - title?: string; - onClose?: () => void; -} - -export type TerminalHandle = { - sendInput: (data: string) => void; - fit: () => void; -}; - -export const Terminal = forwardRef( - ({ hostConfig, isVisible, title = "Terminal", onClose }, ref) => { - const webViewRef = useRef(null); - const { config } = useTerminalCustomization(); - const [webViewKey, setWebViewKey] = useState(0); - const [screenDimensions, setScreenDimensions] = useState( - Dimensions.get("window"), - ); - const [isConnecting, setIsConnecting] = useState(true); - const [isRetrying, setIsRetrying] = useState(false); - const [retryCount, setRetryCount] = useState(0); - const [isConnected, setIsConnected] = useState(false); - const [hasReceivedData, setHasReceivedData] = useState(false); - const [showConnectingOverlay, setShowConnectingOverlay] = useState(true); - const [htmlContent, setHtmlContent] = useState(""); - const [currentHostId, setCurrentHostId] = useState(null); - const connectionTimeoutRef = useRef | null>( - null, - ); - - useEffect(() => { - const subscription = Dimensions.addEventListener( - "change", - ({ window }) => { - setScreenDimensions(window); - }, - ); - - return () => subscription?.remove(); - }, []); - - const handleConnectionFailure = useCallback( - (errorMessage: string) => { - showToast.error(errorMessage); - setIsConnecting(false); - setIsConnected(false); - if (onClose) { - onClose(); - } - }, - [onClose], - ); - - const getWebSocketUrl = async () => { - const serverUrl = getCurrentServerUrl(); - - if (!serverUrl) { - showToast.error( - "No server URL found - please configure a server first", - ); - return null; - } - - const jwtToken = await getCookie("jwt"); - if (!jwtToken || jwtToken.trim() === "") { - showToast.error("Authentication required - please log in again"); - return null; - } - - const wsProtocol = serverUrl.startsWith("https://") ? "wss://" : "ws://"; - const wsHost = serverUrl.replace(/^https?:\/\//, ""); - const cleanHost = wsHost.replace(/\/$/, ""); - const wsUrl = `${wsProtocol}${cleanHost}/ssh/websocket/?token=${encodeURIComponent(jwtToken)}`; - - return wsUrl; - }; - - const generateHTML = useCallback(async () => { - const wsUrl = await getWebSocketUrl(); - const { width, height } = screenDimensions; - - if (!wsUrl) { - return ` - - - - - Terminal - - -
-

No Server Configured

-

Please configure a server first

-
- -`; - } - - // Use font size from context - const baseFontSize = config.fontSize; - const terminalWidth = Math.floor(width / 8); - const terminalHeight = Math.floor(height / 16); - - return ` - - - - - - Terminal - - - - - - -
- - - - - `; - }, [hostConfig, screenDimensions, config.fontSize]); - - useEffect(() => { - const updateHtml = async () => { - const html = await generateHTML(); - setHtmlContent(html); - }; - updateHtml(); - }, [generateHTML]); - - const handleWebViewMessage = useCallback( - (event: any) => { - try { - const message = JSON.parse(event.nativeEvent.data); - - switch (message.type) { - case "connecting": - if (message.data.retryCount > 0) { - setIsRetrying(true); - setIsConnecting(false); - } else { - setIsConnecting(true); - setIsRetrying(false); - } - setRetryCount(message.data.retryCount); - setShowConnectingOverlay(true); - break; - - case "connected": - setIsConnecting(false); - setIsRetrying(false); - setIsConnected(true); - setRetryCount(0); - break; - - case "dataReceived": - setHasReceivedData(true); - setShowConnectingOverlay(false); - break; - - case "disconnected": - setIsConnecting(false); - setIsRetrying(false); - setIsConnected(false); - showToast.warning(`Disconnected from ${message.data.hostName}`); - if (onClose) { - onClose(); - } - break; - - case "connectionFailed": - setIsConnecting(false); - setIsRetrying(false); - handleConnectionFailure( - `${message.data.hostName}: ${message.data.message}`, - ); - break; - } - } catch (error) {} - }, - [handleConnectionFailure, onClose], - ); - - useImperativeHandle( - ref, - () => ({ - sendInput: (data: string) => { - try { - const escaped = JSON.stringify(data); - webViewRef.current?.injectJavaScript( - `window.nativeInput(${escaped}); true;`, - ); - } catch (e) {} - }, - fit: () => { - try { - webViewRef.current?.injectJavaScript( - `window.nativeFit && window.nativeFit(); true;`, - ); - } catch (e) {} - }, - }), - [], - ); - - useEffect(() => { - if (hostConfig.id !== currentHostId) { - setCurrentHostId(hostConfig.id); - setWebViewKey((prev) => prev + 1); - setIsConnecting(true); - setIsRetrying(false); - setIsConnected(false); - setHasReceivedData(false); - setShowConnectingOverlay(true); - setRetryCount(0); - - const updateHtml = async () => { - const html = await generateHTML(); - setHtmlContent(html); - }; - updateHtml(); - } - }, [hostConfig.id, currentHostId]); - - useEffect(() => { - return () => { - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - } - }; - }, []); - - return ( - - - - {}} - onMessage={handleWebViewMessage} - onError={(syntheticEvent) => { - const { nativeEvent } = syntheticEvent; - handleConnectionFailure( - `WebView error: ${nativeEvent.description}`, - ); - }} - onHttpError={(syntheticEvent) => { - const { nativeEvent } = syntheticEvent; - handleConnectionFailure( - `WebView HTTP error: ${nativeEvent.statusCode}`, - ); - }} - scrollEnabled={false} - bounces={false} - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - nestedScrollEnabled={false} - /> - - - {(showConnectingOverlay || isRetrying) && ( - - - - - {isRetrying ? "Reconnecting..." : "Connecting..."} - - - {hostConfig.name} • {hostConfig.ip} - - {retryCount > 0 && ( - - - Retry {retryCount}/3 - - - )} - - - )} - - - ); - }, -); - -export default Terminal; diff --git a/app/_layout.tsx b/app/_layout.tsx index d375942..1dae430 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,22 +4,25 @@ import { TerminalSessionsProvider } from "./contexts/TerminalSessionsContext"; import { TerminalCustomizationProvider } from "./contexts/TerminalCustomizationContext"; import { KeyboardProvider } from "./contexts/KeyboardContext"; import { KeyboardCustomizationProvider } from "./contexts/KeyboardCustomizationContext"; -import ServerForm from "./Authentication/ServerForm"; -import LoginForm from "./Authentication/LoginForm"; -import { View, Text, ActivityIndicator } from "react-native"; +import ServerForm from "@/app/authentication/ServerForm"; +import LoginForm from "@/app/authentication/LoginForm"; +import { View, Text, ActivityIndicator, TouchableOpacity } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { Toaster } from "sonner-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "../global.css"; -import UpdateRequired from "@/app/Authentication/UpdateRequired"; +import UpdateRequired from "@/app/authentication/UpdateRequired"; function RootLayoutContent() { const { showServerManager, + setShowServerManager, showLoginForm, + setShowLoginForm, isAuthenticated, showUpdateScreen, isLoading, + setIsLoading, } = useAppContext(); if (isLoading) { @@ -27,6 +30,15 @@ function RootLayoutContent() { Initializing... + { + setShowLoginForm(false); + setShowServerManager(true); + }} + className="mt-6 px-6 py-3 bg-[#1a1a1a] border border-[#303032] rounded-lg" + > + Cancel + ); } diff --git a/app/Authentication/LoginForm.tsx b/app/authentication/LoginForm.tsx similarity index 93% rename from app/Authentication/LoginForm.tsx rename to app/authentication/LoginForm.tsx index 6bc6988..3537f3d 100644 --- a/app/Authentication/LoginForm.tsx +++ b/app/authentication/LoginForm.tsx @@ -131,6 +131,26 @@ export default function LoginForm() { 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"); @@ -364,7 +384,7 @@ export default function LoginForm() { if (!source.uri) { return ( - + Loading server configuration... ); @@ -394,9 +414,11 @@ export default function LoginForm() { key={webViewKey} ref={webViewRef} source={source} - userAgent={Platform.OS === "android" - ? "Termix-Mobile/Android" - : "Termix-Mobile/iOS"} + userAgent={ + Platform.OS === "android" + ? "Termix-Mobile/Android" + : "Termix-Mobile/iOS" + } style={{ flex: 1, backgroundColor: "#18181b" }} containerStyle={{ backgroundColor: "#18181b" }} onNavigationStateChange={handleNavigationStateChange} @@ -437,7 +459,7 @@ export default function LoginForm() { alignItems: "center", }} > - +
)} /> diff --git a/app/Authentication/ServerForm.tsx b/app/authentication/ServerForm.tsx similarity index 100% rename from app/Authentication/ServerForm.tsx rename to app/authentication/ServerForm.tsx diff --git a/app/Authentication/UpdateRequired.tsx b/app/authentication/UpdateRequired.tsx similarity index 98% rename from app/Authentication/UpdateRequired.tsx rename to app/authentication/UpdateRequired.tsx index ca5f454..eac1f30 100644 --- a/app/Authentication/UpdateRequired.tsx +++ b/app/authentication/UpdateRequired.tsx @@ -65,7 +65,7 @@ export default function UpdateRequired() { className="flex-1 bg-[#18181b] justify-center items-center" style={{ paddingTop: insets.top }} > - + Loading version information... diff --git a/app/constants/designTokens.ts b/app/constants/designTokens.ts new file mode 100644 index 0000000..4e18735 --- /dev/null +++ b/app/constants/designTokens.ts @@ -0,0 +1,61 @@ +/** + * 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 + */ + +export const BORDERS = { + MAJOR: 2, + STANDARD: 1, + SEPARATOR: 1, +} as const; + +export const BORDER_COLORS = { + PRIMARY: "#303032", + SECONDARY: "#373739", + SEPARATOR: "#404040", + BUTTON: "#303032", + ACTIVE: "#22C55E", +} as const; + +export const BACKGROUNDS = { + DARKEST: "#09090b", + DARKER: "#0e0e10", + HEADER: "#131316", + DARK: "#18181b", + CARD: "#1a1a1a", + BUTTON: "#2a2a2a", + BUTTON_ALT: "#23232a", + ACTIVE: "#4a4a4a", + HOVER: "#2d2d30", +} as const; + +export const RADIUS = { + BUTTON: 6, + CARD: 12, + SMALL: 4, + LARGE: 16, +} as const; + +export const SPACING = { + TOOLBAR_PADDING_PORTRAIT: 12, + TOOLBAR_PADDING_LANDSCAPE: 8, + BUTTON_PADDING_PORTRAIT: 8, + BUTTON_PADDING_LANDSCAPE: 6, + CARD_GAP: 12, + BUTTON_GAP: 8, +} as const; + +export const TEXT_COLORS = { + PRIMARY: "#ffffff", + SECONDARY: "#9CA3AF", + TERTIARY: "#6B7280", + DISABLED: "#4B5563", + ACCENT: "#22C55E", +} as const; + +export const ICON_SIZES = { + SMALL: 16, + MEDIUM: 18, + LARGE: 20, +} as const; diff --git a/app/contexts/KeyboardCustomizationContext.tsx b/app/contexts/KeyboardCustomizationContext.tsx index 4bcbc74..f3398d3 100644 --- a/app/contexts/KeyboardCustomizationContext.tsx +++ b/app/contexts/KeyboardCustomizationContext.tsx @@ -16,7 +16,7 @@ import { import { PRESET_DEFINITIONS, getPresetById, -} from "@/app/Tabs/Sessions/KeyDefinitions"; +} from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; const STORAGE_KEY = "keyboardCustomization"; const DEFAULT_PRESET_ID: PresetType = "default"; diff --git a/app/contexts/TerminalCustomizationContext.tsx b/app/contexts/TerminalCustomizationContext.tsx index d1adb9d..2b2c17e 100644 --- a/app/contexts/TerminalCustomizationContext.tsx +++ b/app/contexts/TerminalCustomizationContext.tsx @@ -6,22 +6,21 @@ import React, { useCallback, } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { TerminalConfig } from "@/types"; +import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; -const STORAGE_KEY = "terminalCustomization"; +const STORAGE_KEY = "terminalConfig"; -export interface TerminalCustomization { - fontSize: number; -} - -const getDefaultConfig = (): TerminalCustomization => { - return { - fontSize: 16, - }; +const getDefaultConfig = (): Partial => { + return MOBILE_DEFAULT_TERMINAL_CONFIG; }; interface TerminalCustomizationContextType { - config: TerminalCustomization; + config: Partial; isLoading: boolean; + updateConfig: (config: Partial) => Promise; + resetConfig: () => Promise; + updateFontSize: (fontSize: number) => Promise; resetToDefault: () => Promise; } @@ -34,7 +33,7 @@ export const TerminalCustomizationProvider: React.FC<{ children: React.ReactNode; }> = ({ children }) => { const [config, setConfig] = - useState(getDefaultConfig()); + useState>(getDefaultConfig()); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -42,7 +41,7 @@ export const TerminalCustomizationProvider: React.FC<{ try { const stored = await AsyncStorage.getItem(STORAGE_KEY); if (stored) { - const parsed = JSON.parse(stored) as TerminalCustomization; + const parsed = JSON.parse(stored) as Partial; setConfig(parsed); } } catch (error) { @@ -55,7 +54,7 @@ export const TerminalCustomizationProvider: React.FC<{ loadConfig(); }, []); - const saveConfig = useCallback(async (newConfig: TerminalCustomization) => { + const saveConfig = useCallback(async (newConfig: Partial) => { try { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newConfig)); setConfig(newConfig); @@ -64,24 +63,37 @@ export const TerminalCustomizationProvider: React.FC<{ } }, []); - const updateFontSize = useCallback( - async (fontSize: number) => { + const updateConfig = useCallback( + async (updates: Partial) => { const newConfig = { ...config, - fontSize, + ...updates, }; await saveConfig(newConfig); }, [config, saveConfig], ); - const resetToDefault = useCallback(async () => { + const resetConfig = useCallback(async () => { await saveConfig(getDefaultConfig()); }, [saveConfig]); + const updateFontSize = useCallback( + async (fontSize: number) => { + await updateConfig({ fontSize }); + }, + [updateConfig], + ); + + const resetToDefault = useCallback(async () => { + await resetConfig(); + }, [resetConfig]); + const value: TerminalCustomizationContextType = { config, isLoading, + updateConfig, + resetConfig, updateFontSize, resetToDefault, }; diff --git a/app/contexts/TerminalSessionsContext.tsx b/app/contexts/TerminalSessionsContext.tsx index 7388447..70445c4 100644 --- a/app/contexts/TerminalSessionsContext.tsx +++ b/app/contexts/TerminalSessionsContext.tsx @@ -17,16 +17,23 @@ export interface TerminalSession { title: string; isActive: boolean; createdAt: Date; + type: "terminal" | "stats" | "filemanager" | "tunnel"; } interface TerminalSessionsContextType { sessions: TerminalSession[]; activeSessionId: string | null; - addSession: (host: SSHHost) => string; + addSession: ( + host: SSHHost, + type?: "terminal" | "stats" | "filemanager" | "tunnel", + ) => string; removeSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void; clearAllSessions: () => void; - navigateToSessions: (host?: SSHHost) => void; + navigateToSessions: ( + host?: SSHHost, + type?: "terminal" | "stats" | "filemanager" | "tunnel", + ) => void; isCustomKeyboardVisible: boolean; toggleCustomKeyboard: () => void; lastKeyboardHeight: number; @@ -64,37 +71,54 @@ export const TerminalSessionsProvider: React.FC< const keyboardIntentionallyHiddenRef = useRef(false); const [, forceUpdate] = useState({}); - const addSession = useCallback((host: SSHHost): string => { - setSessions((prev) => { - const existingSessions = prev.filter( - (session) => session.host.id === host.id, - ); + const addSession = useCallback( + ( + host: SSHHost, + type: "terminal" | "stats" | "filemanager" | "tunnel" = "terminal", + ): string => { + setSessions((prev) => { + const existingSessions = prev.filter( + (session) => session.host.id === host.id && session.type === type, + ); - let title = host.name; - if (existingSessions.length > 0) { - title = `${host.name} (${existingSessions.length + 1})`; - } + const typeLabel = + type === "stats" + ? "Stats" + : type === "filemanager" + ? "Files" + : type === "tunnel" + ? "Tunnels" + : ""; + let title = typeLabel ? `${host.name} - ${typeLabel}` : host.name; + if (existingSessions.length > 0) { + title = typeLabel + ? `${host.name} - ${typeLabel} (${existingSessions.length + 1})` + : `${host.name} (${existingSessions.length + 1})`; + } - const sessionId = `${host.id}-${Date.now()}`; - const newSession: TerminalSession = { - id: sessionId, - host, - title, - isActive: true, - createdAt: new Date(), - }; + const sessionId = `${host.id}-${type}-${Date.now()}`; + const newSession: TerminalSession = { + id: sessionId, + host, + title, + isActive: true, + createdAt: new Date(), + type, + }; - const updatedSessions = prev.map((session) => ({ - ...session, - isActive: false, - })); + const updatedSessions = prev.map((session) => ({ + ...session, + isActive: false, + })); - setActiveSessionId(sessionId); - return [...updatedSessions, newSession]; - }); + setActiveSessionId(sessionId); + return [...updatedSessions, newSession]; + }); - return ""; - }, []); + return ""; + }, + [], + ); const removeSession = useCallback( (sessionId: string) => { @@ -109,8 +133,10 @@ export const TerminalSessionsProvider: React.FC< ); const hostId = sessionToRemove.host.id; + const sessionType = sessionToRemove.type; const sameHostSessions = updatedSessions.filter( - (session) => session.host.id === hostId, + (session) => + session.host.id === hostId && session.type === sessionType, ); if (sameHostSessions.length > 0) { @@ -123,12 +149,20 @@ 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" + : ""; + const baseName = typeLabel + ? `${session.host.name} - ${typeLabel}` + : session.host.name; updatedSessions[sessionIndex] = { ...session, - title: - index === 0 - ? session.host.name - : `${session.host.name} (${index + 1})`, + title: index === 0 ? baseName : `${baseName} (${index + 1})`, }; } }); @@ -154,20 +188,32 @@ export const TerminalSessionsProvider: React.FC< [activeSessionId], ); - const setActiveSession = useCallback((sessionId: string) => { - setSessions((prev) => - prev.map((session) => ({ - ...session, - isActive: session.id === sessionId, - })), - ); - setActiveSessionId(sessionId); - }, []); + const setActiveSession = useCallback( + (sessionId: string) => { + setSessions((prev) => { + const newSession = prev.find((s) => s.id === sessionId); + + if (newSession?.type !== "terminal" && isCustomKeyboardVisible) { + setIsCustomKeyboardVisible(false); + } + + return prev.map((session) => ({ + ...session, + isActive: session.id === sessionId, + })); + }); + setActiveSessionId(sessionId); + }, + [isCustomKeyboardVisible], + ); const navigateToSessions = useCallback( - (host?: SSHHost) => { + ( + host?: SSHHost, + type: "terminal" | "stats" | "filemanager" | "tunnel" = "terminal", + ) => { if (host) { - addSession(host); + addSession(host, type); } router.push("/(tabs)/sessions"); }, diff --git a/app/main-axios.ts b/app/main-axios.ts index 9ce4d5d..5aeb01f 100644 --- a/app/main-axios.ts +++ b/app/main-axios.ts @@ -10,6 +10,16 @@ import type { ApiResponse, FileManagerFile, FileManagerShortcut, + ServerStatus, + ServerMetrics, + AuthResponse, + UserInfo, + UserCount, + OIDCAuthorize, + FileManagerOperation, + ServerConfig, + UptimeInfo, + RecentActivityItem, } from "../types/index"; import { apiLogger, @@ -23,80 +33,14 @@ import { } from "../lib/frontend-logger"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import platform from "expo-modules-core/src/Platform"; +import { Platform } from "react-native"; -interface FileManagerOperation { - name: string; - path: string; - isSSH: boolean; - sshSessionId?: string; - hostId: number; -} - -export type ServerStatus = { - status: "online" | "offline"; - lastChecked: string; -}; - -interface CpuMetrics { - percent: number | null; - cores: number | null; - load: [number, number, number] | null; -} - -interface MemoryMetrics { - percent: number | null; - usedGiB: number | null; - totalGiB: number | null; -} - -interface DiskMetrics { - percent: number | null; - usedHuman: string | null; - totalHuman: string | null; -} - -export type ServerMetrics = { - cpu: CpuMetrics; - memory: MemoryMetrics; - disk: DiskMetrics; - lastChecked: string; -}; - -interface AuthResponse { - token: string; - success?: boolean; - is_admin?: boolean; - username?: string; - userId?: string; - is_oidc?: boolean; - totp_enabled?: boolean; - data_unlocked?: boolean; - requires_totp?: boolean; - temp_token?: string; -} - -interface UserInfo { - totp_enabled: boolean; - userId: string; - username: string; - is_admin: boolean; - is_oidc: boolean; - data_unlocked: boolean; -} - -interface UserCount { - count: number; -} +const platform = Platform; // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ -export function isElectron(): boolean { - return false; -} - function getLoggerForService(serviceName: string) { if (serviceName.includes("SSH") || serviceName.includes("ssh")) { return sshLogger; @@ -309,11 +253,6 @@ export function setAuthStateCallback( let configuredServerUrl: string | null = null; -export interface ServerConfig { - serverUrl: string; - lastUpdated: string; -} - export async function saveServerConfig(config: ServerConfig): Promise { try { await AsyncStorage.setItem("serverConfig", JSON.stringify(config)); @@ -725,11 +664,21 @@ export async function createSSHHost(hostData: SSHHostData): Promise { keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], + jumpHosts: hostData.jumpHosts || [], + quickActions: hostData.quickActions || [], + statsConfig: hostData.statsConfig + ? typeof hostData.statsConfig === "string" + ? hostData.statsConfig + : JSON.stringify(hostData.statsConfig) + : null, + terminalConfig: hostData.terminalConfig || null, + forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), }; if (!submitData.enableTunnel) { @@ -781,11 +730,21 @@ export async function updateSSHHost( keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], + jumpHosts: hostData.jumpHosts || [], + quickActions: hostData.quickActions || [], + statsConfig: hostData.statsConfig + ? typeof hostData.statsConfig === "string" + ? hostData.statsConfig + : JSON.stringify(hostData.statsConfig) + : null, + terminalConfig: hostData.terminalConfig || null, + forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), }; if (!submitData.enableTunnel) { @@ -848,6 +807,61 @@ export async function getSSHHostById(hostId: number): Promise { } } +export async function exportSSHHostWithCredentials( + hostId: number, +): Promise { + try { + const response = await sshHostApi.get(`/db/host/${hostId}/export`); + return response.data; + } catch (error) { + handleApiError(error, "export SSH host with credentials"); + } +} + +// ============================================================================ +// SSH AUTOSTART MANAGEMENT +// ============================================================================ + +export async function enableAutoStart(sshConfigId: number): Promise { + try { + const response = await sshHostApi.post("/autostart/enable", { + sshConfigId, + }); + return response.data; + } catch (error) { + handleApiError(error, "enable autostart"); + } +} + +export async function disableAutoStart(sshConfigId: number): Promise { + try { + const response = await sshHostApi.delete("/autostart/disable", { + data: { sshConfigId }, + }); + return response.data; + } catch (error) { + handleApiError(error, "disable autostart"); + } +} + +export async function getAutoStartStatus(): Promise<{ + autostart_configs: { + sshConfigId: number; + host: string; + port: number; + username: string; + authType: string; + }[]; + total_count: number; +}> { + try { + const response = await sshHostApi.get("/autostart/status"); + return response.data; + } catch (error) { + handleApiError(error, "fetch autostart status"); + } +} + // ============================================================================ // TUNNEL MANAGEMENT // ============================================================================ @@ -1029,6 +1043,9 @@ export async function connectSSH( authType?: string; credentialId?: number; userId?: string; + forceKeyboardInteractive?: boolean; + overrideCredentialUsername?: boolean; + jumpHosts?: { hostId: number }[]; }, ): Promise { try { @@ -1066,17 +1083,58 @@ export async function getSSHStatus( } } +export async function verifySSHTOTP( + sessionId: string, + totpCode: string, +): Promise { + try { + const response = await fileManagerApi.post("/ssh/connect-totp", { + sessionId, + totpCode, + }); + return response.data; + } catch (error) { + handleApiError(error, "verify SSH TOTP"); + } +} + +export async function keepSSHAlive(sessionId: string): Promise { + try { + const response = await fileManagerApi.post("/ssh/keepalive", { + sessionId, + }); + return response.data; + } catch (error) { + handleApiError(error, "SSH keepalive"); + } +} + export async function listSSHFiles( sessionId: string, path: string, -): Promise { +): Promise<{ files: any[]; path: string }> { try { const response = await fileManagerApi.get("/ssh/listFiles", { params: { sessionId, path }, }); - return response.data || []; + return response.data || { files: [], path }; } catch (error) { handleApiError(error, "list SSH files"); + return { files: [], path }; + } +} + +export async function identifySSHSymlink( + sessionId: string, + path: string, +): Promise<{ path: string; target: string; type: "directory" | "file" }> { + try { + const response = await fileManagerApi.get("/ssh/identifySymlink", { + params: { sessionId, path }, + }); + return response.data; + } catch (error) { + handleApiError(error, "identify SSH symlink"); } } @@ -1089,7 +1147,13 @@ export async function readSSHFile( params: { sessionId, path }, }); return response.data; - } catch (error) { + } catch (error: any) { + if (error?.response?.status === 404) { + const customError: any = new Error("File not found"); + customError.response = error.response; + customError.isFileNotFound = error.response.data?.fileNotFound || true; + throw customError; + } handleApiError(error, "read SSH file"); } } @@ -1232,130 +1296,502 @@ export async function renameSSHItem( return response.data; } catch (error) { handleApiError(error, "rename SSH item"); + throw error; } } -// ============================================================================ -// SERVER STATISTICS -// ============================================================================ - -export async function getAllServerStatuses(): Promise< - Record -> { +export async function downloadSSHFile( + sessionId: string, + filePath: string, + hostId?: number, + userId?: string, +): Promise { try { - const response = await statsApi.get("/status"); - return response.data || {}; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getRootBase(8085), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get("/status"); - return response.data || {}; - } catch (e) { - handleApiError(e, "fetch server statuses"); - } - } - handleApiError(error, "fetch server statuses"); + const response = await fileManagerApi.post("/ssh/downloadFile", { + sessionId, + path: filePath, + hostId, + userId, + }); + return response.data; + } catch (error) { + handleApiError(error, "download SSH file"); } } -export async function getServerStatusById(id: number): Promise { +export async function copySSHItem( + sessionId: string, + sourcePath: string, + targetDir: string, + hostId?: number, + userId?: string, +): Promise { try { - const response = await statsApi.get(`/status/${id}`); + const response = await fileManagerApi.post( + "/ssh/copyItem", + { + sessionId, + sourcePath, + targetDir, + hostId, + userId, + }, + { + timeout: 60000, + }, + ); return response.data; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getRootBase(8085), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get(`/status/${id}`); - return response.data; - } catch (e) { - handleApiError(e, "fetch server status"); - } - } - handleApiError(error, "fetch server status"); + } catch (error) { + handleApiError(error, "copy SSH item"); + throw error; } } -export async function getServerMetricsById(id: number): Promise { +export async function moveSSHItem( + sessionId: string, + oldPath: string, + newPath: string, + hostId?: number, + userId?: string, +): Promise { try { - const response = await statsApi.get(`/metrics/${id}`); + const response = await fileManagerApi.put( + "/ssh/moveItem", + { + sessionId, + oldPath, + newPath, + hostId, + userId, + }, + { + timeout: 60000, + }, + ); return response.data; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getRootBase(8085), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.get(`/metrics/${id}`); - return response.data; - } catch (e) { - handleApiError(e, "fetch server metrics"); - } - } - handleApiError(error, "fetch server metrics"); + } catch (error) { + handleApiError(error, "move SSH item"); + throw error; } } -// ============================================================================ -// AUTHENTICATION -// ============================================================================ - -export async function registerUser( - username: string, - password: string, -): Promise { +export async function changeSSHPermissions( + sessionId: string, + path: string, + permissions: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string }> { try { - const response = await authApi.post("/users/create", { - username, - password, + fileLogger.info("Changing SSH file permissions", { + operation: "change_permissions", + sessionId, + path, + permissions, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/changePermissions", { + sessionId, + path, + permissions, + hostId, + userId, + }); + + fileLogger.success("SSH file permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions, }); + return response.data; - } catch (error: any) { - if (error?.response?.status === 404) { - try { - const alt = axios.create({ - baseURL: getSshBase(8081), - headers: { "Content-Type": "application/json" }, - }); - const response = await alt.post("/users/create", { - username, - password, - }); - return response.data; - } catch (e) { - handleApiError(e, "register user"); - } - } - handleApiError(error, "register user"); + } catch (error) { + fileLogger.error("Failed to change SSH file permissions", error, { + operation: "change_permissions", + sessionId, + path, + permissions, + }); + handleApiError(error, "change SSH permissions"); + throw error; } } -export async function loginUser( - username: string, - password: string, -): Promise { +export async function extractSSHArchive( + sessionId: string, + archivePath: string, + extractPath?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; extractPath: string }> { try { - const response = await authApi.post("/users/login", { username, password }); + fileLogger.info("Extracting archive", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); - if (response.data.requires_totp) { - return { - ...response.data, - token: response.data.temp_token || "", - }; - } + const response = await fileManagerApi.post("/ssh/extractArchive", { + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); - let token = null; - const cookieHeader = response.headers["set-cookie"]; - if (cookieHeader && Array.isArray(cookieHeader)) { - for (const cookie of cookieHeader) { + fileLogger.success("Archive extracted successfully", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: response.data.extractPath, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to extract archive", error, { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + }); + handleApiError(error, "extract archive"); + throw error; + } +} + +export async function compressSSHFiles( + sessionId: string, + paths: string[], + archiveName: string, + format?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; archivePath: string }> { + try { + fileLogger.info("Compressing files", { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/compressFiles", { + sessionId, + paths, + archiveName, + format: format || "zip", + hostId, + userId, + }); + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath: response.data.archivePath, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to compress files", error, { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + }); + handleApiError(error, "compress files"); + throw error; + } +} + +// ============================================================================ +// FILE MANAGER DATA +// ============================================================================ + +export async function getRecentFiles(hostId: number): Promise { + try { + const response = await authApi.get("/ssh/file_manager/recent", { + params: { hostId }, + }); + return response.data; + } catch (error) { + handleApiError(error, "get recent files"); + throw error; + } +} + +export async function addRecentFile( + hostId: number, + path: string, + name?: string, +): Promise { + try { + const response = await authApi.post("/ssh/file_manager/recent", { + hostId, + path, + name, + }); + return response.data; + } catch (error) { + handleApiError(error, "add recent file"); + throw error; + } +} + +export async function removeRecentFile( + hostId: number, + path: string, +): Promise { + try { + const response = await authApi.delete("/ssh/file_manager/recent", { + data: { hostId, path }, + }); + return response.data; + } catch (error) { + handleApiError(error, "remove recent file"); + throw error; + } +} + +export async function getPinnedFiles(hostId: number): Promise { + try { + const response = await authApi.get("/ssh/file_manager/pinned", { + params: { hostId }, + }); + return response.data; + } catch (error) { + handleApiError(error, "get pinned files"); + throw error; + } +} + +export async function addPinnedFile( + hostId: number, + path: string, + name?: string, +): Promise { + try { + const response = await authApi.post("/ssh/file_manager/pinned", { + hostId, + path, + name, + }); + return response.data; + } catch (error) { + handleApiError(error, "add pinned file"); + throw error; + } +} + +export async function removePinnedFile( + hostId: number, + path: string, +): Promise { + try { + const response = await authApi.delete("/ssh/file_manager/pinned", { + data: { hostId, path }, + }); + return response.data; + } catch (error) { + handleApiError(error, "remove pinned file"); + throw error; + } +} + +export async function getFolderShortcuts(hostId: number): Promise { + try { + const response = await authApi.get("/ssh/file_manager/shortcuts", { + params: { hostId }, + }); + return response.data; + } catch (error) { + handleApiError(error, "get folder shortcuts"); + throw error; + } +} + +export async function addFolderShortcut( + hostId: number, + path: string, + name?: string, +): Promise { + try { + const response = await authApi.post("/ssh/file_manager/shortcuts", { + hostId, + path, + name, + }); + return response.data; + } catch (error) { + handleApiError(error, "add folder shortcut"); + throw error; + } +} + +export async function removeFolderShortcut( + hostId: number, + path: string, +): Promise { + try { + const response = await authApi.delete("/ssh/file_manager/shortcuts", { + data: { hostId, path }, + }); + return response.data; + } catch (error) { + handleApiError(error, "remove folder shortcut"); + throw error; + } +} + +// ============================================================================ +// SERVER STATISTICS +// ============================================================================ + +export async function getAllServerStatuses(): Promise< + Record +> { + try { + const response = await statsApi.get("/status"); + return response.data || {}; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getRootBase(8085), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.get("/status"); + return response.data || {}; + } catch (e) { + handleApiError(e, "fetch server statuses"); + } + } + handleApiError(error, "fetch server statuses"); + } +} + +export async function getServerStatusById(id: number): Promise { + try { + const response = await statsApi.get(`/status/${id}`); + return response.data; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getRootBase(8085), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.get(`/status/${id}`); + return response.data; + } catch (e) { + handleApiError(e, "fetch server status"); + } + } + handleApiError(error, "fetch server status"); + } +} + +export async function getServerMetricsById(id: number): Promise { + try { + const response = await statsApi.get(`/metrics/${id}`); + return response.data; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getRootBase(8085), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.get(`/metrics/${id}`); + return response.data; + } catch (e) { + handleApiError(e, "fetch server metrics"); + } + } + handleApiError(error, "fetch server metrics"); + } +} + +export async function refreshServerPolling(): Promise { + try { + await statsApi.post("/refresh"); + } catch (error) { + console.warn("Failed to refresh server polling:", error); + } +} + +export async function notifyHostCreatedOrUpdated( + hostId: number, +): Promise { + try { + await statsApi.post("/host-updated", { hostId }); + } catch (error) { + console.warn("Failed to notify stats server of host update:", error); + } +} + +// ============================================================================ +// AUTHENTICATION +// ============================================================================ + +export async function registerUser( + username: string, + password: string, +): Promise { + try { + const response = await authApi.post("/users/create", { + username, + password, + }); + return response.data; + } catch (error: any) { + if (error?.response?.status === 404) { + try { + const alt = axios.create({ + baseURL: getSshBase(8081), + headers: { "Content-Type": "application/json" }, + }); + const response = await alt.post("/users/create", { + username, + password, + }); + return response.data; + } catch (e) { + handleApiError(e, "register user"); + } + } + handleApiError(error, "register user"); + } +} + +export async function loginUser( + username: string, + password: string, +): Promise { + try { + const response = await authApi.post("/users/login", { username, password }); + + if (response.data.requires_totp) { + return { + ...response.data, + token: response.data.temp_token || "", + }; + } + + let token = null; + const cookieHeader = response.headers["set-cookie"]; + if (cookieHeader && Array.isArray(cookieHeader)) { + for (const cookie of cookieHeader) { if (cookie.startsWith("jwt=")) { token = cookie.split("jwt=")[1].split(";")[0]; break; @@ -1451,6 +1887,17 @@ export async function getUserInfo(): Promise { } } +export async function unlockUserData( + password: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post("/users/unlock-data", { password }); + return response.data; + } catch (error) { + handleApiError(error, "unlock user data"); + } +} + export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> { try { const response = await authApi.get("/users/registration-allowed"); @@ -1472,6 +1919,46 @@ export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> { } } +export async function getPasswordLoginAllowed(): Promise<{ allowed: boolean }> { + try { + const response = await authApi.get("/users/password-login-allowed"); + return response.data; + } catch (error) { + handleApiError(error, "check password login status"); + } +} + +export async function getOIDCConfig(): Promise { + try { + const response = await authApi.get("/users/oidc-config"); + return response.data; + } catch (error: any) { + console.warn( + "Failed to fetch OIDC config:", + error.response?.data?.error || error.message, + ); + return null; + } +} + +export async function getAdminOIDCConfig(): Promise { + try { + const response = await authApi.get("/users/oidc-config/admin"); + return response.data; + } catch (error) { + handleApiError(error, "fetch admin OIDC config"); + } +} + +export async function getSetupRequired(): Promise<{ setup_required: boolean }> { + try { + const response = await authApi.get("/users/setup-required"); + return response.data; + } catch (error) { + handleApiError(error, "check setup status"); + } +} + export async function getUserCount(): Promise { try { const response = await authApi.get("/users/count"); @@ -1522,6 +2009,30 @@ 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"); + return response.data; + } catch (error) { + handleApiError(error, "get OIDC authorize URL"); + } +} + // ============================================================================ // USER MANAGEMENT // ============================================================================ @@ -1535,6 +2046,53 @@ export async function getUserList(): Promise<{ users: UserInfo[] }> { } } +export async function getSessions(): Promise<{ + sessions: { + id: string; + userId: string; + username?: string; + deviceType: string; + deviceInfo: string; + createdAt: string; + expiresAt: string; + lastActiveAt: string; + jwtToken: string; + isRevoked?: boolean; + }[]; +}> { + try { + const response = await authApi.get("/users/sessions"); + return response.data; + } catch (error) { + handleApiError(error, "fetch sessions"); + } +} + +export async function revokeSession( + sessionId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.delete(`/users/sessions/${sessionId}`); + return response.data; + } catch (error) { + handleApiError(error, "revoke session"); + } +} + +export async function revokeAllUserSessions( + userId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post("/users/sessions/revoke-all", { + targetUserId: userId, + exceptCurrent: false, + }); + return response.data; + } catch (error) { + handleApiError(error, "revoke all user sessions"); + } +} + export async function makeUserAdmin(username: string): Promise { try { const response = await authApi.post("/users/make-admin", { username }); @@ -1584,7 +2142,38 @@ export async function updateRegistrationAllowed( }); return response.data; } catch (error) { - handleApiError(error, "update registration allowed"); + handleApiError(error, "update registration allowed"); + } +} + +export async function updatePasswordLoginAllowed( + allowed: boolean, +): Promise<{ allowed: boolean }> { + try { + const response = await authApi.patch("/users/password-login-allowed", { + allowed, + }); + return response.data; + } catch (error) { + handleApiError(error, "update password login allowed"); + } +} + +export async function updateOIDCConfig(config: any): Promise { + try { + const response = await authApi.post("/users/oidc-config", config); + return response.data; + } catch (error) { + handleApiError(error, "update OIDC config"); + } +} + +export async function disableOIDCConfig(): Promise { + try { + const response = await authApi.delete("/users/oidc-config"); + return response.data; + } catch (error) { + handleApiError(error, "disable OIDC config"); } } @@ -1729,23 +2318,18 @@ export async function generateBackupCodes( } } -export async function getUserAlerts( - userId: string, -): Promise<{ alerts: any[] }> { +export async function getUserAlerts(): Promise<{ alerts: any[] }> { try { - const response = await authApi.get(`/alerts/user/${userId}`); + const response = await authApi.get(`/alerts`); return response.data; } catch (error) { handleApiError(error, "fetch user alerts"); } } -export async function dismissAlert( - userId: string, - alertId: string, -): Promise { +export async function dismissAlert(alertId: string): Promise { try { - const response = await authApi.post("/alerts/dismiss", { userId, alertId }); + const response = await authApi.post("/alerts/dismiss", { alertId }); return response.data; } catch (error) { handleApiError(error, "dismiss alert"); @@ -2067,6 +2651,92 @@ export async function renameFolder( } } +export async function getSSHFolders(): Promise { + try { + sshLogger.info("Fetching SSH folders", { + operation: "fetch_ssh_folders", + }); + + const response = await authApi.get("/ssh/folders"); + + sshLogger.success("SSH folders fetched successfully", { + operation: "fetch_ssh_folders", + count: response.data.length, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to fetch SSH folders", error, { + operation: "fetch_ssh_folders", + }); + handleApiError(error, "fetch SSH folders"); + throw error; + } +} + +export async function updateFolderMetadata( + name: string, + color?: string, + icon?: string, +): Promise { + try { + sshLogger.info("Updating folder metadata", { + operation: "update_folder_metadata", + name, + color, + icon, + }); + + await authApi.put("/ssh/folders/metadata", { + name, + color, + icon, + }); + + sshLogger.success("Folder metadata updated successfully", { + operation: "update_folder_metadata", + name, + }); + } catch (error) { + sshLogger.error("Failed to update folder metadata", error, { + operation: "update_folder_metadata", + name, + }); + handleApiError(error, "update folder metadata"); + throw error; + } +} + +export async function deleteAllHostsInFolder( + folderName: string, +): Promise<{ deletedCount: number }> { + try { + sshLogger.info("Deleting all hosts in folder", { + operation: "delete_folder_hosts", + folderName, + }); + + const response = await authApi.delete( + `/ssh/folders/${encodeURIComponent(folderName)}/hosts`, + ); + + sshLogger.success("All hosts in folder deleted successfully", { + operation: "delete_folder_hosts", + folderName, + deletedCount: response.data.deletedCount, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to delete hosts in folder", error, { + operation: "delete_folder_hosts", + folderName, + }); + handleApiError(error, "delete hosts in folder"); + throw error; + } +} + export async function renameCredentialFolder( oldName: string, newName: string, @@ -2079,5 +2749,337 @@ export async function renameCredentialFolder( return response.data; } catch (error) { handleApiError(error, "rename credential folder"); + throw error; + } +} + +export async function detectKeyType( + privateKey: string, + keyPassword?: string, +): Promise { + try { + const response = await authApi.post("/credentials/detect-key-type", { + privateKey, + keyPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "detect key type"); + throw error; + } +} + +export async function detectPublicKeyType(publicKey: string): Promise { + try { + const response = await authApi.post("/credentials/detect-public-key-type", { + publicKey, + }); + return response.data; + } catch (error) { + handleApiError(error, "detect public key type"); + throw error; + } +} + +export async function validateKeyPair( + privateKey: string, + publicKey: string, + keyPassword?: string, +): Promise { + try { + const response = await authApi.post("/credentials/validate-key-pair", { + privateKey, + publicKey, + keyPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "validate key pair"); + throw error; + } +} + +export async function generatePublicKeyFromPrivate( + privateKey: string, + keyPassword?: string, +): Promise { + try { + const response = await authApi.post("/credentials/generate-public-key", { + privateKey, + keyPassword, + }); + return response.data; + } catch (error) { + handleApiError(error, "generate public key from private key"); + throw error; + } +} + +export async function generateKeyPair( + keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", + keySize?: number, + passphrase?: string, +): Promise { + try { + const response = await authApi.post("/credentials/generate-key-pair", { + keyType, + keySize, + passphrase, + }); + return response.data; + } catch (error) { + handleApiError(error, "generate SSH key pair"); + throw error; + } +} + +export async function deployCredentialToHost( + credentialId: number, + targetHostId: number, +): Promise { + try { + const response = await authApi.post( + `/credentials/${credentialId}/deploy-to-host`, + { targetHostId }, + ); + return response.data; + } catch (error) { + handleApiError(error, "deploy credential to host"); + throw error; + } +} + +// ============================================================================ +// SNIPPETS API +// ============================================================================ + +export async function getSnippets(): Promise { + try { + const response = await authApi.get("/snippets"); + return response.data; + } catch (error) { + handleApiError(error, "fetch snippets"); + throw error; + } +} + +export async function createSnippet(snippetData: any): Promise { + try { + const response = await authApi.post("/snippets", snippetData); + return response.data; + } catch (error) { + handleApiError(error, "create snippet"); + throw error; + } +} + +export async function updateSnippet( + snippetId: number, + snippetData: any, +): Promise { + try { + const response = await authApi.put(`/snippets/${snippetId}`, snippetData); + return response.data; + } catch (error) { + handleApiError(error, "update snippet"); + throw error; + } +} + +export async function deleteSnippet(snippetId: number): Promise { + try { + const response = await authApi.delete(`/snippets/${snippetId}`); + return response.data; + } catch (error) { + handleApiError(error, "delete snippet"); + throw error; + } +} + +export async function executeSnippet( + snippetId: number, + hostId: number, +): Promise<{ success: boolean; output: string; error?: string }> { + try { + const response = await authApi.post("/snippets/execute", { + snippetId, + hostId, + }); + return response.data; + } catch (error) { + handleApiError(error, "execute snippet"); + throw error; + } +} + +export async function reorderSnippets( + snippets: { id: number; order: number; folder?: string }[], +): Promise<{ success: boolean; updated: number }> { + try { + const response = await authApi.put("/snippets/reorder", { snippets }); + return response.data; + } catch (error) { + handleApiError(error, "reorder snippets"); + throw error; + } +} + +export async function getSnippetFolders(): Promise { + try { + const response = await authApi.get("/snippets/folders"); + return response.data; + } catch (error) { + handleApiError(error, "fetch snippet folders"); + throw error; + } +} + +export async function createSnippetFolder(folderData: { + name: string; + color?: string; + icon?: string; +}): Promise { + try { + const response = await authApi.post("/snippets/folders", folderData); + return response.data; + } catch (error) { + handleApiError(error, "create snippet folder"); + throw error; + } +} + +export async function updateSnippetFolderMetadata( + folderName: string, + metadata: { color?: string; icon?: string }, +): Promise { + try { + const response = await authApi.put( + `/snippets/folders/${encodeURIComponent(folderName)}/metadata`, + metadata, + ); + return response.data; + } catch (error) { + handleApiError(error, "update snippet folder metadata"); + throw error; + } +} + +export async function renameSnippetFolder( + oldName: string, + newName: string, +): Promise<{ success: boolean; oldName: string; newName: string }> { + try { + const response = await authApi.put("/snippets/folders/rename", { + oldName, + newName, + }); + return response.data; + } catch (error) { + handleApiError(error, "rename snippet folder"); + throw error; + } +} + +export async function deleteSnippetFolder( + folderName: string, +): Promise<{ success: boolean }> { + try { + const response = await authApi.delete( + `/snippets/folders/${encodeURIComponent(folderName)}`, + ); + return response.data; + } catch (error) { + handleApiError(error, "delete snippet folder"); + throw error; + } +} + +// ============================================================================ +// HOMEPAGE API +// ============================================================================ + +export async function getUptime(): Promise { + try { + const response = await authApi.get("/uptime"); + return response.data; + } catch (error) { + handleApiError(error, "fetch uptime"); + throw error; + } +} + +export async function getRecentActivity( + limit?: number, +): Promise { + try { + const response = await authApi.get("/activity/recent", { + params: { limit }, + }); + return response.data; + } catch (error) { + handleApiError(error, "fetch recent activity"); + throw error; + } +} + +export async function logActivity( + type: "terminal" | "file_manager", + hostId: number, + hostName: string, +): Promise<{ message: string; id: number | string }> { + try { + const response = await authApi.post("/activity/log", { + type, + hostId, + hostName, + }); + return response.data; + } catch (error) { + handleApiError(error, "log activity"); + throw error; + } +} + +export async function resetRecentActivity(): Promise<{ message: string }> { + try { + const response = await authApi.delete("/activity/reset"); + return response.data; + } catch (error) { + handleApiError(error, "reset recent activity"); + throw error; + } +} + +// ============================================================================ +// OIDC ACCOUNT LINKING +// ============================================================================ + +export async function linkOIDCToPasswordAccount( + oidcUserId: string, + targetUsername: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post("/users/link-oidc-to-password", { + oidcUserId, + targetUsername, + }); + return response.data; + } catch (error) { + handleApiError(error, "link OIDC account to password account"); + throw error; + } +} + +export async function unlinkOIDCFromPasswordAccount( + userId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post("/users/unlink-oidc-from-password", { + userId, + }); + return response.data; + } catch (error) { + handleApiError(error, "unlink OIDC from password account"); + throw error; } } diff --git a/app/tabs/dialogs/SSHAuthDialog.tsx b/app/tabs/dialogs/SSHAuthDialog.tsx new file mode 100644 index 0000000..e0b6d66 --- /dev/null +++ b/app/tabs/dialogs/SSHAuthDialog.tsx @@ -0,0 +1,405 @@ +import React, { + useState, + useEffect, + useCallback, + useMemo, + useRef, +} from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + Modal, + ScrollView, + Platform, + KeyboardAvoidingView, + TouchableWithoutFeedback, +} from "react-native"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding } from "@/app/utils/responsive"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +interface SSHAuthDialogProps { + visible: boolean; + onSubmit: (credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) => void; + onCancel: () => void; + hostInfo: { + name?: string; + ip: string; + port: number; + username: string; + }; + reason: "no_keyboard" | "auth_failed" | "timeout"; +} + +const SSHAuthDialogComponent: React.FC = ({ + visible, + onSubmit, + onCancel, + hostInfo, + reason, +}) => { + const [authMethod, setAuthMethod] = useState<"password" | "key">("password"); + const [password, setPassword] = useState(""); + const [sshKey, setSshKey] = useState(""); + const [keyPassword, setKeyPassword] = useState(""); + const { isLandscape } = useOrientation(); + const insets = useSafeAreaInsets(); + const padding = getResponsivePadding(isLandscape); + const passwordInputRef = useRef(null); + const sshKeyInputRef = useRef(null); + + useEffect(() => { + if (!visible) { + setPassword(""); + setSshKey(""); + setKeyPassword(""); + setAuthMethod("password"); + } + }, [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(""); + } + }, [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], + ); + + return ( + + + + + + 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 new file mode 100644 index 0000000..5ca4be1 --- /dev/null +++ b/app/tabs/dialogs/TOTPDialog.tsx @@ -0,0 +1,252 @@ +import React, { + useState, + useEffect, + useCallback, + useMemo, + useRef, +} from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + Modal, + ScrollView, + Platform, + KeyboardAvoidingView, +} 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"; + +interface TOTPDialogProps { + visible: boolean; + onSubmit: (code: string) => void; + onCancel: () => void; + prompt?: string; + isPasswordPrompt?: boolean; +} + +const TOTPDialogComponent: React.FC = ({ + visible, + onSubmit, + onCancel, + prompt = "Two-Factor Authentication", + isPasswordPrompt = false, +}) => { + const [code, setCode] = useState(""); + const { isLandscape } = useOrientation(); + const insets = useSafeAreaInsets(); + const padding = getResponsivePadding(isLandscape); + const inputRef = useRef(null); + + useEffect(() => { + if (!visible) { + setCode(""); + } + }, [visible]); + + useEffect(() => { + if (visible) { + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 300); + return () => clearTimeout(timer); + } + }, [visible]); + + const handleSubmit = useCallback(() => { + if (code.trim()) { + onSubmit(code); + setCode(""); + } + }, [code, onSubmit]); + + const handleCancel = useCallback(() => { + setCode(""); + onCancel(); + }, [onCancel]); + + const handlePaste = useCallback(async () => { + try { + const clipboardContent = await Clipboard.getString(); + if (clipboardContent) { + const pastedCode = isPasswordPrompt + ? clipboardContent + : clipboardContent.replace(/\D/g, "").slice(0, 6); + setCode(pastedCode); + } + } catch (error) { + console.error("Failed to paste from clipboard:", error); + } + }, [isPasswordPrompt]); + + const isCodeValid = useMemo(() => code.trim().length > 0, [code]); + + return ( + + + + + + {prompt} + + + {isPasswordPrompt + ? "Enter your password to continue" + : "Enter your TOTP verification code"} + + + + + + + + + + + Cancel + + + + + {isPasswordPrompt ? "Submit" : "Verify"} + + + + + + + + ); +}; + +export const TOTPDialog = React.memo(TOTPDialogComponent); diff --git a/app/tabs/dialogs/index.ts b/app/tabs/dialogs/index.ts new file mode 100644 index 0000000..b7bbf81 --- /dev/null +++ b/app/tabs/dialogs/index.ts @@ -0,0 +1,2 @@ +export { TOTPDialog } from "./TOTPDialog"; +export { SSHAuthDialog } from "./SSHAuthDialog"; diff --git a/app/Tabs/Hosts/Hosts.tsx b/app/tabs/hosts/Hosts.tsx similarity index 83% rename from app/Tabs/Hosts/Hosts.tsx rename to app/tabs/hosts/Hosts.tsx index 60ec0eb..02c2ec0 100644 --- a/app/Tabs/Hosts/Hosts.tsx +++ b/app/tabs/hosts/Hosts.tsx @@ -9,18 +9,20 @@ import { RefreshControl, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useCallback, useRef } from "react"; +import { useFocusEffect } from "@react-navigation/native"; import { RefreshCw } from "lucide-react-native"; -import Folder from "@/app/Tabs/Hosts/Navigation/Folder"; +import Folder from "@/app/tabs/hosts/navigation/Folder"; import { getSSHHosts, getFoldersWithStats, getAllServerStatuses, initializeServerConfig, getCurrentServerUrl, - ServerStatus, } from "@/app/main-axios"; -import { SSHHost } from "@/types"; +import { SSHHost, ServerStatus } from "@/types"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding, getColumnCount } from "@/app/utils/responsive"; interface FolderData { name: string; @@ -36,6 +38,7 @@ interface FolderData { export default function Hosts() { const insets = useSafeAreaInsets(); + const { width, isLandscape } = useOrientation(); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -45,6 +48,9 @@ export default function Hosts() { >({}); const isRefreshingRef = useRef(false); + const padding = getResponsivePadding(isLandscape); + const columnCount = getColumnCount(width, isLandscape, 400); + const fetchData = useCallback(async (isRefresh = false) => { if (isRefreshingRef.current) return; @@ -119,11 +125,21 @@ export default function Hosts() { setFolders(foldersArray); setServerStatuses(statuses); - } catch (error) { - Alert.alert( - "Error", - "Failed to load hosts. Please check your connection and try again.", - ); + } catch (error: any) { + console.error("[Hosts] Error loading hosts:", error); + + const isAuthError = + 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); + } } finally { setLoading(false); setRefreshing(false); @@ -137,9 +153,11 @@ export default function Hosts() { } }, [fetchData]); - useEffect(() => { - fetchData(); - }, [fetchData]); + useFocusEffect( + useCallback(() => { + fetchData(); + }, [fetchData]), + ); const filteredFolders = folders .map((folder) => ({ @@ -165,7 +183,7 @@ export default function Hosts() { className="flex-1 bg-dark-bg px-6 justify-center items-center" style={{ paddingTop: insets.top + 24 }} > - + Loading hosts... ); @@ -173,8 +191,8 @@ export default function Hosts() { return ( diff --git a/app/Tabs/Hosts/Navigation/Folder.tsx b/app/tabs/hosts/navigation/Folder.tsx similarity index 98% rename from app/Tabs/Hosts/Navigation/Folder.tsx rename to app/tabs/hosts/navigation/Folder.tsx index e68ea53..f47802e 100644 --- a/app/Tabs/Hosts/Navigation/Folder.tsx +++ b/app/tabs/hosts/navigation/Folder.tsx @@ -1,5 +1,5 @@ import { View, Text, TouchableOpacity, Animated } from "react-native"; -import Host from "@/app/Tabs/Hosts/Navigation/Host"; +import Host from "@/app/tabs/hosts/navigation/Host"; import { ChevronDown } from "lucide-react-native"; import { useState, useRef, useEffect } from "react"; import { SSHHost } from "@/types"; diff --git a/app/Tabs/Hosts/Navigation/Host.tsx b/app/tabs/hosts/navigation/Host.tsx similarity index 66% rename from app/Tabs/Hosts/Navigation/Host.tsx rename to app/tabs/hosts/navigation/Host.tsx index 8125b26..8960c49 100644 --- a/app/Tabs/Hosts/Navigation/Host.tsx +++ b/app/tabs/hosts/navigation/Host.tsx @@ -6,6 +6,7 @@ import { TouchableWithoutFeedback, Animated, Easing, + ScrollView, } from "react-native"; import { Terminal, @@ -15,10 +16,13 @@ import { Lock, MoreVertical, X, + Activity, } 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"; interface HostProps { host: SSHHost; @@ -28,11 +32,22 @@ interface HostProps { function Host({ host, status, isLast = false }: HostProps) { const { navigateToSessions } = useTerminalSessions(); + const insets = useSafeAreaInsets(); const [showContextMenu, setShowContextMenu] = useState(false); const [tagsContainerWidth, setTagsContainerWidth] = useState(0); const statusLabel = status === "online" ? "UP" : status === "offline" ? "DOWN" : "UNK"; + const parsedStatsConfig: StatsConfig = (() => { + try { + return host.statsConfig + ? JSON.parse(host.statsConfig) + : DEFAULT_STATS_CONFIG; + } catch { + return DEFAULT_STATS_CONFIG; + } + })(); + const getStatusColor = () => { switch (status) { case "online": @@ -95,7 +110,17 @@ function Host({ host, status, isLast = false }: HostProps) { }; const handleTerminalPress = () => { - navigateToSessions(host); + navigateToSessions(host, "terminal"); + setShowContextMenu(false); + }; + + const handleStatsPress = () => { + navigateToSessions(host, "stats"); + setShowContextMenu(false); + }; + + const handleFileManagerPress = () => { + navigateToSessions(host, "filemanager"); setShowContextMenu(false); }; @@ -298,11 +323,18 @@ function Host({ host, status, isLast = false }: HostProps) { transparent={true} animationType="fade" onRequestClose={handleCloseContextMenu} + supportedOrientations={["portrait", "landscape"]} > {}}> - + - - {host.enableTerminal && ( + + + {host.enableTerminal && ( + + + + + 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 + + + + )} + - - - - Open SSH Terminal - - - {host.ip} - {host.username ? ` • ${host.username}` : ""} - - + + Close - )} - - - - Close - - + + diff --git a/app/tabs/sessions/Sessions.tsx b/app/tabs/sessions/Sessions.tsx new file mode 100644 index 0000000..15c7d4c --- /dev/null +++ b/app/tabs/sessions/Sessions.tsx @@ -0,0 +1,902 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { + View, + Text, + ScrollView, + TouchableOpacity, + StyleSheet, + Keyboard, + KeyboardAvoidingView, + Platform, + TextInput, + TouchableWithoutFeedback, + Pressable, + Dimensions, + BackHandler, + AppState, + LayoutAnimation, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { useRouter } from "expo-router"; +import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; +import { useKeyboard } from "@/app/contexts/KeyboardContext"; +import { + Terminal, + TerminalHandle, +} from "@/app/tabs/sessions/terminal/Terminal"; +import { + ServerStats, + ServerStatsHandle, +} from "@/app/tabs/sessions/server-stats/ServerStats"; +import { + FileManager, + FileManagerHandle, +} from "@/app/tabs/sessions/file-manager/FileManager"; +import { + TunnelManager, + TunnelManagerHandle, +} from "@/app/tabs/sessions/tunnel/TunnelManager"; +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 { ArrowLeft } from "lucide-react-native"; +import { useOrientation } from "@/app/utils/orientation"; +import { getMaxKeyboardHeight, getTabBarHeight } from "@/app/utils/responsive"; +import { + BACKGROUNDS, + BORDER_COLORS, + BORDERS, +} from "@/app/constants/designTokens"; + +export default function Sessions() { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { height, isLandscape } = useOrientation(); + const { + sessions, + activeSessionId, + setActiveSession, + removeSession, + isCustomKeyboardVisible, + toggleCustomKeyboard, + lastKeyboardHeight, + setLastKeyboardHeight, + keyboardIntentionallyHiddenRef, + setKeyboardIntentionallyHidden, + } = useTerminalSessions(); + const { keyboardHeight, isKeyboardVisible } = useKeyboard(); + const hiddenInputRef = useRef(null); + const terminalRefs = useRef>>( + {}, + ); + const statsRefs = useRef>>( + {}, + ); + const fileManagerRefs = useRef< + Record> + >({}); + const tunnelManagerRefs = useRef< + Record> + >({}); + const [activeModifiers, setActiveModifiers] = useState({ + ctrl: false, + alt: false, + }); + const [screenDimensions, setScreenDimensions] = useState( + Dimensions.get("window"), + ); + const [keyboardType, setKeyboardType] = useState("default"); + const lastBlurTimeRef = useRef(0); + const [terminalBackgroundColors, setTerminalBackgroundColors] = useState< + Record + >({}); + const isSelectingRef = useRef(false); + const keyboardWasHiddenBeforeSelectionRef = useRef(false); + + const maxKeyboardHeight = getMaxKeyboardHeight(height, isLandscape); + const effectiveKeyboardHeight = isLandscape + ? Math.min(lastKeyboardHeight, maxKeyboardHeight) + : lastKeyboardHeight; + const currentKeyboardHeight = isLandscape + ? Math.min(keyboardHeight, maxKeyboardHeight) + : keyboardHeight; + + const customKeyboardHeight = Math.max( + 200, + Math.min(effectiveKeyboardHeight, 500), + ); + + const SESSION_TAB_BAR_HEIGHT = getTabBarHeight(isLandscape) + 2; + const CUSTOM_KEYBOARD_TAB_HEIGHT = 36; + + const KEYBOARD_BAR_HEIGHT = isLandscape ? 48 : 52; + const KEYBOARD_BAR_HEIGHT_EXTENDED = isLandscape ? 64 : 68; + + const getTabBarBottomPosition = () => { + if (activeSession?.type !== "terminal") { + return insets.bottom; + } + + if (isCustomKeyboardVisible) { + return CUSTOM_KEYBOARD_TAB_HEIGHT + customKeyboardHeight; + } + + if (keyboardIntentionallyHiddenRef.current) { + return KEYBOARD_BAR_HEIGHT_EXTENDED; + } + + if (isKeyboardVisible && currentKeyboardHeight > 0) { + return KEYBOARD_BAR_HEIGHT + currentKeyboardHeight; + } + + return KEYBOARD_BAR_HEIGHT; + }; + + const getBottomMargin = ( + sessionType: "terminal" | "stats" | "filemanager" = "terminal", + ) => { + if (sessionType !== "terminal") { + return SESSION_TAB_BAR_HEIGHT + insets.bottom; + } + + if (isCustomKeyboardVisible) { + return ( + SESSION_TAB_BAR_HEIGHT + + CUSTOM_KEYBOARD_TAB_HEIGHT + + customKeyboardHeight + ); + } + + if (keyboardIntentionallyHiddenRef.current) { + return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT_EXTENDED; + } + + if (isKeyboardVisible && currentKeyboardHeight > 0) { + return ( + SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT + currentKeyboardHeight + ); + } + + return SESSION_TAB_BAR_HEIGHT + KEYBOARD_BAR_HEIGHT; + }; + + useEffect(() => { + const terminalMap: Record> = { + ...terminalRefs.current, + }; + const statsMap: Record> = { + ...statsRefs.current, + }; + const fileManagerMap: Record> = { + ...fileManagerRefs.current, + }; + + sessions.forEach((s) => { + if (s.type === "terminal" && !terminalMap[s.id]) { + terminalMap[s.id] = + React.createRef() as React.RefObject; + } else if (s.type === "stats" && !statsMap[s.id]) { + statsMap[s.id] = + React.createRef() as React.RefObject; + } else if (s.type === "filemanager" && !fileManagerMap[s.id]) { + fileManagerMap[s.id] = + React.createRef() as React.RefObject; + } + }); + + Object.keys(terminalMap).forEach((id) => { + if (!sessions.find((s) => s.id === id && s.type === "terminal")) { + delete terminalMap[id]; + } + }); + + Object.keys(statsMap).forEach((id) => { + if (!sessions.find((s) => s.id === id && s.type === "stats")) { + delete statsMap[id]; + } + }); + + Object.keys(fileManagerMap).forEach((id) => { + if (!sessions.find((s) => s.id === id && s.type === "filemanager")) { + delete fileManagerMap[id]; + } + }); + + terminalRefs.current = terminalMap; + statsRefs.current = statsMap; + fileManagerRefs.current = fileManagerMap; + }, [sessions]); + + useFocusEffect( + React.useCallback(() => { + if ( + sessions.length > 0 && + activeSession?.type === "terminal" && + !isCustomKeyboardVisible && + !keyboardIntentionallyHiddenRef.current + ) { + const timeoutId = setTimeout(() => { + hiddenInputRef.current?.focus(); + }, 500); + return () => clearTimeout(timeoutId); + } + + return () => {}; + }, [ + sessions.length, + activeSession?.type, + isCustomKeyboardVisible, + keyboardIntentionallyHiddenRef, + ]), + ); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextAppState) => { + if (nextAppState === "active") { + sessions.forEach((session) => { + if (session.type === "terminal") { + const terminalRef = terminalRefs.current[session.id]; + if (terminalRef?.current) { + terminalRef.current.notifyForegrounded(); + } + } + }); + + if ( + sessions.length > 0 && + activeSession?.type === "terminal" && + !isCustomKeyboardVisible && + !keyboardIntentionallyHiddenRef.current + ) { + setTimeout(() => { + hiddenInputRef.current?.focus(); + }, 500); + } + } else if (nextAppState === "background") { + sessions.forEach((session) => { + if (session.type === "terminal") { + const terminalRef = terminalRefs.current[session.id]; + if (terminalRef?.current) { + terminalRef.current.notifyBackgrounded(); + } + } + }); + } + }); + + return () => { + subscription.remove(); + }; + }, [sessions, activeSession?.type, isCustomKeyboardVisible]); + + useEffect(() => { + if (Platform.OS === "android" && sessions.length > 0) { + const backHandler = BackHandler.addEventListener( + "hardwareBackPress", + () => { + if (isKeyboardVisible) { + setKeyboardIntentionallyHidden(true); + Keyboard.dismiss(); + return true; + } + return true; + }, + ); + + return () => { + backHandler.remove(); + }; + } + }, [sessions.length, isKeyboardVisible]); + + useEffect(() => { + const subscription = Dimensions.addEventListener("change", ({ window }) => { + setScreenDimensions(window); + + setTimeout(() => { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + activeRef?.current?.fit(); + }, 300); + }); + + return () => subscription?.remove(); + }, [activeSessionId]); + + useEffect(() => { + if (keyboardHeight > 0) { + setLastKeyboardHeight(keyboardHeight); + } + }, [keyboardHeight, setLastKeyboardHeight]); + + useEffect(() => { + if (!activeSessionId || activeSession?.type !== "terminal") return; + + const checkSelectionState = () => { + const activeRef = terminalRefs.current[activeSessionId]; + if (!activeRef?.current) return; + + const isCurrentlySelecting = activeRef.current.isSelecting(); + + if (isCurrentlySelecting && !isSelectingRef.current) { + isSelectingRef.current = true; + + keyboardWasHiddenBeforeSelectionRef.current = + keyboardIntentionallyHiddenRef.current; + + if (!keyboardIntentionallyHiddenRef.current) { + setKeyboardIntentionallyHidden(true); + hiddenInputRef.current?.blur(); + Keyboard.dismiss(); + } else { + } + } else if (!isCurrentlySelecting && isSelectingRef.current) { + isSelectingRef.current = false; + + if (!keyboardWasHiddenBeforeSelectionRef.current) { + setKeyboardIntentionallyHidden(false); + if (!isCustomKeyboardVisible) { + setTimeout(() => { + hiddenInputRef.current?.focus(); + }, 100); + } + } else { + } + + keyboardWasHiddenBeforeSelectionRef.current = false; + } + }; + + const interval = setInterval(checkSelectionState, 50); + return () => clearInterval(interval); + }, [ + activeSessionId, + activeSession?.type, + isCustomKeyboardVisible, + setKeyboardIntentionallyHidden, + ]); + + useEffect(() => { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + if (activeRef && activeRef.current) { + setTimeout(() => { + activeRef.current?.fit(); + }, 100); + } + }, [ + keyboardHeight, + activeSessionId, + screenDimensions, + isCustomKeyboardVisible, + customKeyboardHeight, + ]); + + useFocusEffect( + React.useCallback(() => { + if ( + sessions.length > 0 && + activeSession?.type === "terminal" && + !isCustomKeyboardVisible && + !keyboardIntentionallyHiddenRef.current + ) { + setTimeout(() => { + hiddenInputRef.current?.focus(); + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + activeRef?.current?.fit(); + }, 0); + } + }, [ + sessions.length, + activeSessionId, + activeSession?.type, + isCustomKeyboardVisible, + keyboardIntentionallyHiddenRef, + ]), + ); + + const handleTabPress = (sessionId: string) => { + const session = sessions.find((s) => s.id === sessionId); + setKeyboardIntentionallyHidden(false); + + setActiveSession(sessionId); + setTimeout(() => { + if (session?.type === "terminal" && !isCustomKeyboardVisible) { + hiddenInputRef.current?.focus(); + } + }, 100); + }; + + const handleTabClose = (sessionId: string) => { + removeSession(sessionId); + setTimeout(() => { + if ( + activeSession?.type === "terminal" && + !isCustomKeyboardVisible && + sessions.length > 1 + ) { + hiddenInputRef.current?.focus(); + } + }, 100); + }; + + const handleAddSession = () => { + router.navigate("/hosts" as any); + }; + + const handleToggleKeyboard = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + + if (isCustomKeyboardVisible) { + toggleCustomKeyboard(); + setKeyboardIntentionallyHidden(false); + setTimeout(() => { + hiddenInputRef.current?.focus(); + }, 50); + setTimeout(() => { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + if (activeRef?.current) { + activeRef.current.fit(); + setTimeout(() => { + activeRef.current?.scrollToBottom(); + }, 50); + } + }, 300); + } else { + toggleCustomKeyboard(); + setKeyboardIntentionallyHidden(false); + requestAnimationFrame(() => { + hiddenInputRef.current?.blur(); + }); + setTimeout(() => { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + if (activeRef?.current) { + activeRef.current.fit(); + setTimeout(() => { + activeRef.current?.scrollToBottom(); + }, 50); + } + }, 300); + } + }; + + const handleModifierChange = useCallback( + (modifiers: { ctrl: boolean; alt: boolean }) => { + setActiveModifiers(modifiers); + }, + [], + ); + + const activeSession = sessions.find( + (session) => session.id === activeSessionId, + ); + + const activeTerminalBgColor = + activeSession?.type === "terminal" && activeSessionId + ? terminalBackgroundColors[activeSessionId] || BACKGROUNDS.DARKEST + : BACKGROUNDS.DARKEST; + + return ( + + + {sessions.map((session) => { + if (session.type === "terminal") { + return ( + handleTabClose(session.id)} + onBackgroundColorChange={(color) => { + setTerminalBackgroundColors((prev) => ({ + ...prev, + [session.id]: color, + })); + }} + /> + ); + } else if (session.type === "stats") { + return ( + handleTabClose(session.id)} + /> + ); + } else if (session.type === "filemanager") { + return ( + + ); + } else if (session.type === "tunnel") { + return ( + handleTabClose(session.id)} + /> + ); + } + return null; + })} + + + {sessions.length === 0 && ( + + + + No Active Terminal Sessions + + + Connect to a host from the Hosts tab to start a session + + { + handleAddSession(); + }} + > + + Go to Hosts + + + + + )} + + {sessions.length > 0 && + activeSession?.type === "terminal" && + !isCustomKeyboardVisible && ( + 0 + ? currentKeyboardHeight + (isLandscape ? 4 : 0) + : 0, + left: 0, + right: 0, + height: keyboardIntentionallyHiddenRef.current + ? KEYBOARD_BAR_HEIGHT_EXTENDED + : KEYBOARD_BAR_HEIGHT, + zIndex: 1003, + overflow: "visible", + justifyContent: "center", + }} + > + () + } + isVisible={true} + onModifierChange={handleModifierChange} + isKeyboardIntentionallyHidden={ + keyboardIntentionallyHiddenRef.current + } + /> + + )} + + {sessions.length > 0 && + (activeSession?.type === "stats" || + activeSession?.type === "filemanager") && + isCustomKeyboardVisible && ( + + )} + + + setKeyboardIntentionallyHidden(true)} + onShowKeyboard={() => setKeyboardIntentionallyHidden(false)} + keyboardIntentionallyHiddenRef={keyboardIntentionallyHiddenRef} + activeSessionType={activeSession?.type} + /> + + + {sessions.length > 0 && + isCustomKeyboardVisible && + activeSession?.type === "terminal" && ( + + () + } + isVisible={isCustomKeyboardVisible} + keyboardHeight={customKeyboardHeight} + isKeyboardIntentionallyHidden={ + keyboardIntentionallyHiddenRef.current + } + /> + + )} + + {sessions.length > 0 && + !isCustomKeyboardVisible && + activeSession?.type === "terminal" && ( + {}} + onKeyPress={({ nativeEvent }) => { + const key = nativeEvent.key; + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + + if (!activeRef?.current) return; + + let finalKey = key; + + if (activeModifiers.ctrl) { + switch (key.toLowerCase()) { + case "c": + finalKey = "\x03"; + break; + case "d": + finalKey = "\x04"; + break; + case "z": + finalKey = "\x1a"; + break; + case "l": + finalKey = "\x0c"; + break; + case "a": + finalKey = "\x01"; + break; + case "e": + finalKey = "\x05"; + break; + case "k": + finalKey = "\x0b"; + break; + case "u": + finalKey = "\x15"; + break; + case "w": + finalKey = "\x17"; + break; + default: + if (key.length === 1) { + finalKey = String.fromCharCode(key.charCodeAt(0) & 0x1f); + } + } + } else if (activeModifiers.alt) { + finalKey = `\x1b${key}`; + } + + if (key === "Enter") { + activeRef.current.sendInput("\r"); + } else if (key === "Backspace") { + activeRef.current.sendInput("\b"); + } else if (key.length === 1) { + activeRef.current.sendInput(finalKey); + } + }} + onFocus={() => { + setKeyboardIntentionallyHidden(false); + }} + onBlur={() => { + const activeRef = activeSessionId + ? terminalRefs.current[activeSessionId] + : null; + const isDialogOpen = + activeRef?.current?.isDialogOpen?.() || false; + const isCurrentlySelecting = + activeRef?.current?.isSelecting?.() || false; + + if ( + !keyboardIntentionallyHiddenRef.current && + !isCustomKeyboardVisible && + activeSession?.type === "terminal" && + !isDialogOpen && + !isCurrentlySelecting && + !isSelectingRef.current + ) { + requestAnimationFrame(() => { + const stillNotSelecting = + !activeRef?.current?.isSelecting?.(); + if (stillNotSelecting) { + hiddenInputRef.current?.focus(); + } + }); + } + }} + /> + )} + + ); +} diff --git a/app/tabs/sessions/file-manager/ContextMenu.tsx b/app/tabs/sessions/file-manager/ContextMenu.tsx new file mode 100644 index 0000000..3a2bd8a --- /dev/null +++ b/app/tabs/sessions/file-manager/ContextMenu.tsx @@ -0,0 +1,198 @@ +import { + Modal, + View, + Text, + TouchableOpacity, + TouchableWithoutFeedback, +} from "react-native"; +import { + Eye, + Edit, + Copy, + Scissors, + Trash2, + FileText, + Download, + Lock, + Archive, + PackageOpen, + X, +} from "lucide-react-native"; + +interface ContextMenuProps { + visible: boolean; + onClose: () => void; + fileName: string; + fileType: "file" | "directory" | "link"; + onView?: () => void; + onEdit?: () => void; + onRename: () => void; + onCopy: () => void; + onCut: () => void; + onDelete: () => void; + onDownload?: () => void; + onPermissions?: () => void; + onCompress?: () => void; + onExtract?: () => void; + isArchive?: boolean; +} + +export function ContextMenu({ + visible, + onClose, + fileName, + fileType, + onView, + onEdit, + onRename, + onCopy, + onCut, + onDelete, + onDownload, + onPermissions, + onCompress, + onExtract, + isArchive = false, +}: ContextMenuProps) { + const handleAction = (action: () => void) => { + action(); + onClose(); + }; + + return ( + + + + {}}> + + + + {fileName} + + + + + + + + {onView && fileType === "file" && ( + handleAction(onView)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + View + + )} + + {onEdit && fileType === "file" && ( + handleAction(onEdit)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Edit + + )} + + handleAction(onRename)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Rename + + + handleAction(onCopy)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Copy + + + handleAction(onCut)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Cut + + + {onDownload && fileType === "file" && ( + handleAction(onDownload)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Download + + )} + + {onPermissions && ( + handleAction(onPermissions)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Permissions + + )} + + {onCompress && ( + handleAction(onCompress)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Compress + + )} + + {onExtract && isArchive && ( + handleAction(onExtract)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Extract + + )} + + handleAction(onDelete)} + className="flex-row items-center gap-3 p-3 rounded-md bg-dark-bg-darker border border-dark-border" + activeOpacity={0.7} + > + + Delete + + + + + + + + ); +} diff --git a/app/tabs/sessions/file-manager/FileItem.tsx b/app/tabs/sessions/file-manager/FileItem.tsx new file mode 100644 index 0000000..c7a093a --- /dev/null +++ b/app/tabs/sessions/file-manager/FileItem.tsx @@ -0,0 +1,111 @@ +import { View, Text, TouchableOpacity } from "react-native"; +import { File, Folder, Link } from "lucide-react-native"; +import { + formatFileSize, + formatDate, + getFileIconColor, +} from "./utils/fileUtils"; + +interface FileItemProps { + name: string; + type: "file" | "directory" | "link"; + size?: number; + modified?: string; + isSelected?: boolean; + onPress: () => void; + onLongPress: () => void; + onSelectToggle?: () => void; + selectionMode?: boolean; + columnCount?: number; + useGrid?: boolean; +} + +export function FileItem({ + name, + type, + size, + modified, + isSelected = false, + onPress, + onLongPress, + onSelectToggle, + selectionMode = false, + columnCount = 1, + useGrid = false, +}: FileItemProps) { + const iconColor = getFileIconColor(name, type); + const IconComponent = + type === "directory" ? Folder : type === "link" ? Link : File; + + return ( + + {selectionMode && ( + + + {isSelected && ( + + )} + + + )} + + + + + + + + {name} + + + {type === "directory" ? ( + Folder + ) : ( + <> + {size !== undefined && ( + + {formatFileSize(size)} + + )} + {modified && ( + <> + {size !== undefined && ( + + )} + + {formatDate(modified)} + + + )} + + )} + + + + {type === "link" && !selectionMode && ( + + + + )} + + ); +} diff --git a/app/tabs/sessions/file-manager/FileList.tsx b/app/tabs/sessions/file-manager/FileList.tsx new file mode 100644 index 0000000..abcd997 --- /dev/null +++ b/app/tabs/sessions/file-manager/FileList.tsx @@ -0,0 +1,96 @@ +import { ScrollView, RefreshControl, View, Text } from "react-native"; +import { FileItem } from "./FileItem"; +import { sortFiles } from "./utils/fileUtils"; +import { getColumnCount } from "@/app/utils/responsive"; + +interface FileListItem { + name: string; + path: string; + type: "file" | "directory" | "link"; + size?: number; + modified?: string; + permissions?: string; +} + +interface FileListProps { + files: FileListItem[]; + onFilePress: (file: FileListItem) => void; + onFileLongPress: (file: FileListItem) => void; + selectedFiles: string[]; + onSelectToggle: (path: string) => void; + selectionMode: boolean; + isLoading: boolean; + onRefresh: () => void; + sortBy?: "name" | "size" | "modified"; + sortOrder?: "asc" | "desc"; + isLandscape: boolean; + width: number; + toolbarHeight: number; +} + +export function FileList({ + files, + onFilePress, + onFileLongPress, + selectedFiles, + onSelectToggle, + selectionMode, + isLoading, + onRefresh, + sortBy = "name", + sortOrder = "asc", + isLandscape, + width, + toolbarHeight, +}: FileListProps) { + const sortedFiles = sortFiles(files, sortBy, sortOrder); + + if (!isLoading && files.length === 0) { + return ( + + } + > + This folder is empty + + ); + } + + return ( + 0 ? toolbarHeight + 12 : 12, + }} + refreshControl={ + + } + > + {sortedFiles.map((file) => ( + onFilePress(file)} + onLongPress={() => onFileLongPress(file)} + onSelectToggle={() => onSelectToggle(file.path)} + selectionMode={selectionMode} + /> + ))} + + ); +} diff --git a/app/tabs/sessions/file-manager/FileManager.tsx b/app/tabs/sessions/file-manager/FileManager.tsx new file mode 100644 index 0000000..944f3fa --- /dev/null +++ b/app/tabs/sessions/file-manager/FileManager.tsx @@ -0,0 +1,742 @@ +import { + useState, + useEffect, + useRef, + useCallback, + forwardRef, + useImperativeHandle, +} from "react"; +import { + View, + Alert, + TextInput, + Modal, + Text, + TouchableOpacity, + ActivityIndicator, + KeyboardAvoidingView, + Platform, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Server } from "lucide-react-native"; +import { SSHHost } from "@/types"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding, getTabBarHeight } from "@/app/utils/responsive"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; +import { + connectSSH, + listSSHFiles, + readSSHFile, + writeSSHFile, + createSSHFile, + createSSHFolder, + deleteSSHItem, + renameSSHItem, + copySSHItem, + moveSSHItem, + verifySSHTOTP, + keepSSHAlive, + identifySSHSymlink, +} 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 { + joinPath, + isTextFile, + isArchiveFile, +} from "@/app/tabs/sessions/file-manager/utils/fileUtils"; +import { showToast } from "@/app/utils/toast"; +import { TOTPDialog } from "@/app/tabs/dialogs"; + +interface FileManagerProps { + host: SSHHost; + sessionId: string; + isVisible: boolean; +} + +interface FileItem { + name: string; + path: string; + type: "file" | "directory" | "link"; + size?: number; + modified?: string; + permissions?: string; +} + +export interface FileManagerHandle { + handleDisconnect: () => void; +} + +export const FileManager = forwardRef( + ({ host, sessionId, isVisible }, ref) => { + 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 [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 [createName, setCreateName] = useState(""); + const [renameDialog, setRenameDialog] = useState<{ + visible: boolean; + file: FileItem | null; + }>({ visible: false, file: null }); + const [renameName, setRenameName] = useState(""); + const [fileViewer, setFileViewer] = useState<{ + visible: boolean; + file: FileItem | null; + content: string; + }>({ visible: false, file: null, content: "" }); + + const keepaliveInterval = useRef(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]); + + 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"); + } + }; + + const loadDirectory = useCallback( + async (path: string) => { + if (!sessionId) return; + + try { + setIsLoading(true); + const response = await listSSHFiles(sessionId, path); + setFiles(response.files || []); + setCurrentPath(response.path || path); + } catch (error: any) { + showToast.error(error.message || "Failed to load directory"); + } finally { + setIsLoading(false); + } + }, + [sessionId], + ); + + const handleFilePress = async (file: FileItem) => { + 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); + } else { + showToast.info("File type not supported for viewing"); + } + } catch (error: any) { + showToast.error(error.message || "Failed to follow symlink"); + } finally { + setIsLoading(false); + } + return; + } + + if (file.type === "directory") { + loadDirectory(file.path); + } else { + handleViewFile(file); + } + }; + + const handleFileLongPress = (file: FileItem) => { + setContextMenu({ visible: true, file }); + }; + + const handleViewFile = async (file: FileItem) => { + try { + setIsLoading(true); + const response = await readSSHFile(sessionId!, file.path); + setFileViewer({ visible: true, file, content: response.content }); + } catch (error: any) { + showToast.error(error.message || "Failed to read file"); + } finally { + setIsLoading(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 handleCreateFile = () => { + setCreateDialog({ visible: true, type: "file" }); + setCreateName(""); + }; + + const handleCreateConfirm = async () => { + if (!createDialog.type || !createName.trim()) return; + + try { + setIsLoading(true); + if (createDialog.type === "folder") { + await createSSHFolder(sessionId!, currentPath, createName, host.id); + showToast.success("Folder created successfully"); + } else { + await createSSHFile(sessionId!, currentPath, createName, "", host.id); + showToast.success("File created successfully"); + } + setCreateDialog({ visible: false, type: null }); + setCreateName(""); + await loadDirectory(currentPath); + } catch (error: any) { + showToast.error(error.message || "Failed to create item"); + } finally { + setIsLoading(false); + } + }; + + const handleRename = (file: FileItem) => { + setRenameDialog({ visible: true, file }); + setRenameName(file.name); + }; + + const handleRenameConfirm = async () => { + if (!renameDialog.file || !renameName.trim()) return; + + try { + setIsLoading(true); + await renameSSHItem( + sessionId!, + renameDialog.file.path, + renameName, + host.id, + ); + showToast.success("Item renamed successfully"); + setRenameDialog({ visible: false, file: null }); + setRenameName(""); + await loadDirectory(currentPath); + } catch (error: any) { + showToast.error(error.message || "Failed to rename item"); + } finally { + setIsLoading(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 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 handlePaste = async () => { + if (clipboard.files.length === 0 || !clipboard.operation) return; + + try { + setIsLoading(true); + for (const filePath of clipboard.files) { + if (clipboard.operation === "copy") { + await copySSHItem(sessionId!, filePath, currentPath, host.id); + } else { + await moveSSHItem( + sessionId!, + filePath, + joinPath(currentPath, filePath.split("/").pop()!), + host.id, + ); + } + } + showToast.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"); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async (file?: FileItem) => { + const filesToDelete = file + ? [file] + : files.filter((f) => selectedFiles.includes(f.path)); + + Alert.alert( + "Confirm Delete", + `Are you sure you want to delete ${filesToDelete.length} item(s)?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + setIsLoading(true); + for (const fileItem of filesToDelete) { + await deleteSSHItem( + sessionId!, + fileItem.path, + fileItem.type === "directory", + host.id, + ); + } + showToast.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 handleSelectToggle = (path: string) => { + setSelectedFiles((prev) => + prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], + ); + }; + + const handleCancelSelection = () => { + setSelectionMode(false); + setSelectedFiles([]); + }; + + useEffect(() => { + connectToSSH(); + + return () => { + if (keepaliveInterval.current) { + clearInterval(keepaliveInterval.current); + } + }; + }, [connectToSSH]); + + useImperativeHandle(ref, () => ({ + handleDisconnect: () => { + if (keepaliveInterval.current) { + clearInterval(keepaliveInterval.current); + } + setIsConnected(false); + }, + })); + + 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 padding = getResponsivePadding(isLandscape); + const tabBarHeight = getTabBarHeight(isLandscape); + + 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; + + 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} + /> + + {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)} + /> + )} + + + + + + + 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} + > + + Cancel + + + + + Create + + + + + + + + + + + + + + Rename Item + + + + { + 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} + > + + Cancel + + + + + Rename + + + + + + + + + {fileViewer.file && ( + + setFileViewer({ visible: false, file: null, content: "" }) + } + fileName={fileViewer.file.name} + filePath={fileViewer.file.path} + initialContent={fileViewer.content} + onSave={handleSaveFile} + /> + )} + + ); + }, +); + +FileManager.displayName = "FileManager"; diff --git a/app/tabs/sessions/file-manager/FileManagerHeader.tsx b/app/tabs/sessions/file-manager/FileManagerHeader.tsx new file mode 100644 index 0000000..daf8ae3 --- /dev/null +++ b/app/tabs/sessions/file-manager/FileManagerHeader.tsx @@ -0,0 +1,226 @@ +import { View, Text, TouchableOpacity, ScrollView } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + ChevronLeft, + RefreshCw, + FolderPlus, + FilePlus, + Upload, + MoreVertical, +} from "lucide-react-native"; +import { breadcrumbsFromPath, getBreadcrumbLabel } from "./utils/fileUtils"; +import { + getResponsivePadding, + getResponsiveFontSize, +} from "@/app/utils/responsive"; +import { + BORDERS, + BORDER_COLORS, + BACKGROUNDS, + RADIUS, +} from "@/app/constants/designTokens"; + +interface FileManagerHeaderProps { + currentPath: string; + onNavigateToPath: (path: string) => void; + onRefresh: () => void; + onCreateFolder: () => void; + onCreateFile: () => void; + onUpload?: () => void; + onMenuPress: () => void; + isLoading: boolean; + isLandscape: boolean; +} + +export function FileManagerHeader({ + currentPath, + onNavigateToPath, + onRefresh, + onCreateFolder, + onCreateFile, + onUpload, + onMenuPress, + isLoading, + isLandscape, +}: FileManagerHeaderProps) { + const insets = useSafeAreaInsets(); + const breadcrumbs = breadcrumbsFromPath(currentPath); + const isRoot = currentPath === "/"; + const padding = getResponsivePadding(isLandscape); + const iconSize = isLandscape ? 16 : 18; + const chevronSize = isLandscape ? 18 : 20; + const buttonPadding = isLandscape ? 6 : 8; + + return ( + + + + {!isRoot && ( + { + const parentPath = breadcrumbs[breadcrumbs.length - 2] || "/"; + onNavigateToPath(parentPath); + }} + style={{ marginRight: 8, padding: 4 }} + activeOpacity={0.7} + > + + + )} + + {breadcrumbs.map((path, index) => ( + + {index > 0 && breadcrumbs[index - 1] !== "/" && ( + + / + + )} + onNavigateToPath(path)} + style={{ + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: RADIUS.SMALL, + backgroundColor: + index === breadcrumbs.length - 1 + ? BACKGROUNDS.BUTTON_ALT + : "transparent", + }} + activeOpacity={0.7} + > + + {getBreadcrumbLabel(path)} + + + + ))} + + + + + + + + + + + + + + + + + {onUpload && ( + + + + )} + + + + + + + + + ); +} diff --git a/app/tabs/sessions/file-manager/FileManagerToolbar.tsx b/app/tabs/sessions/file-manager/FileManagerToolbar.tsx new file mode 100644 index 0000000..37a8a0a --- /dev/null +++ b/app/tabs/sessions/file-manager/FileManagerToolbar.tsx @@ -0,0 +1,215 @@ +import { View, Text, TouchableOpacity } from "react-native"; +import { Copy, Scissors, Clipboard, Trash2, X } from "lucide-react-native"; +import { getResponsivePadding } from "@/app/utils/responsive"; +import { + BORDERS, + BORDER_COLORS, + BACKGROUNDS, + RADIUS, +} from "@/app/constants/designTokens"; + +interface FileManagerToolbarProps { + selectionMode: boolean; + selectedCount: number; + onCopy: () => void; + onCut: () => void; + onPaste: () => void; + onDelete: () => void; + onCancelSelection: () => void; + onCancelClipboard?: () => void; + clipboardCount?: number; + clipboardOperation?: "copy" | "cut" | null; + isLandscape: boolean; + bottomInset: number; + tabBarHeight: number; +} + +export function FileManagerToolbar({ + selectionMode, + selectedCount, + onCopy, + onCut, + onPaste, + onDelete, + onCancelSelection, + onCancelClipboard, + clipboardCount = 0, + clipboardOperation = null, + isLandscape, + bottomInset, + tabBarHeight, +}: FileManagerToolbarProps) { + if (!selectionMode && clipboardCount === 0) { + return null; + } + + const padding = getResponsivePadding(isLandscape); + const iconSize = isLandscape ? 18 : 20; + const buttonPadding = isLandscape ? 6 : 8; + const bottomPosition = isLandscape ? bottomInset - 20 : 0; + + return ( + + {selectionMode ? ( + + + {selectedCount} selected + + + + + + + + + + + + + + + + + + + + + ) : ( + + + {clipboardCount} item{clipboardCount !== 1 ? "s" : ""}{" "} + {clipboardOperation === "copy" ? "copied" : "cut"} + + + + + + + + {onCancelClipboard && ( + + + + )} + + + )} + + ); +} diff --git a/app/tabs/sessions/file-manager/FileViewer.tsx b/app/tabs/sessions/file-manager/FileViewer.tsx new file mode 100644 index 0000000..6fea01f --- /dev/null +++ b/app/tabs/sessions/file-manager/FileViewer.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect } from "react"; +import { + Modal, + View, + Text, + TouchableOpacity, + TextInput, + ActivityIndicator, + Alert, + Platform, + KeyboardAvoidingView, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { X, Save, RotateCcw } from "lucide-react-native"; +import { showToast } from "@/app/utils/toast"; +import { useOrientation } from "@/app/utils/orientation"; + +interface FileViewerProps { + visible: boolean; + onClose: () => void; + fileName: string; + filePath: string; + initialContent: string; + onSave: (content: string) => Promise; + readOnly?: boolean; +} + +const MONOSPACE_FONT = Platform.select({ + ios: "Courier", + android: "monospace", + default: "monospace", +}); + +export function FileViewer({ + visible, + onClose, + fileName, + filePath, + initialContent, + onSave, + readOnly = false, +}: FileViewerProps) { + const insets = useSafeAreaInsets(); + const { isLandscape } = useOrientation(); + const [content, setContent] = useState(initialContent); + const [isSaving, setIsSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + setContent(initialContent); + setHasChanges(false); + }, [initialContent, visible]); + + const handleContentChange = (newContent: string) => { + setContent(newContent); + setHasChanges(newContent !== initialContent); + }; + + const handleSave = async () => { + if (!hasChanges || readOnly) return; + + try { + setIsSaving(true); + await onSave(content); + setHasChanges(false); + } catch (error: any) { + showToast.error(error.message || "Failed to save file"); + } finally { + setIsSaving(false); + } + }; + + const handleRevert = () => { + if (!hasChanges) return; + + Alert.alert( + "Revert Changes", + "Are you sure you want to discard your changes?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Revert", + style: "destructive", + onPress: () => { + setContent(initialContent); + setHasChanges(false); + }, + }, + ], + ); + }; + + const handleClose = () => { + if (hasChanges && !readOnly) { + Alert.alert( + "Unsaved Changes", + "You have unsaved changes. Do you want to save before closing?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: onClose, + }, + { + text: "Save", + onPress: async () => { + await handleSave(); + onClose(); + }, + }, + ], + ); + } else { + onClose(); + } + }; + + return ( + + + + + + + + {fileName} + + + {filePath} + + + + + {!readOnly && hasChanges && ( + <> + + + + + + {isSaving ? ( + + ) : ( + + )} + + + )} + + + + + + + + {readOnly && ( + + Read-only mode + + )} + + + + + + + ); +} diff --git a/app/tabs/sessions/file-manager/utils/fileUtils.ts b/app/tabs/sessions/file-manager/utils/fileUtils.ts new file mode 100644 index 0000000..1d90051 --- /dev/null +++ b/app/tabs/sessions/file-manager/utils/fileUtils.ts @@ -0,0 +1,245 @@ +export function formatFileSize(bytes: number | undefined): string { + if (!bytes || bytes === 0) return "0 B"; + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +export function getFileExtension(filename: string): string { + const parts = filename.split("."); + if (parts.length === 1) return ""; + return parts[parts.length - 1].toLowerCase(); +} + +export function getFileName(path: string): string { + const parts = path.split("/"); + return parts[parts.length - 1] || path; +} + +export function getParentPath(path: string): string { + if (path === "/" || !path) return "/"; + const parts = path.split("/").filter((p) => p); + parts.pop(); + return "/" + parts.join("/"); +} + +export function joinPath(...parts: string[]): string { + const joined = parts + .map((part) => part.replace(/^\/+|\/+$/g, "")) + .filter((part) => part) + .join("/"); + return "/" + joined; +} + +export function isTextFile(filename: string): boolean { + const ext = getFileExtension(filename); + const textExtensions = [ + "txt", + "md", + "json", + "xml", + "html", + "css", + "js", + "ts", + "tsx", + "jsx", + "py", + "java", + "c", + "cpp", + "h", + "hpp", + "cs", + "php", + "rb", + "go", + "rs", + "sh", + "bash", + "zsh", + "fish", + "yml", + "yaml", + "toml", + "ini", + "cfg", + "conf", + "log", + "env", + "gitignore", + "dockerignore", + "editorconfig", + "prettierrc", + ]; + return textExtensions.includes(ext); +} + +export function isArchiveFile(filename: string): boolean { + const ext = getFileExtension(filename); + const archiveExtensions = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"]; + return archiveExtensions.includes(ext); +} + +export function isImageFile(filename: string): boolean { + const ext = getFileExtension(filename); + const imageExtensions = [ + "jpg", + "jpeg", + "png", + "gif", + "bmp", + "svg", + "webp", + "ico", + ]; + return imageExtensions.includes(ext); +} + +export function isVideoFile(filename: string): boolean { + const ext = getFileExtension(filename); + const videoExtensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm"]; + return videoExtensions.includes(ext); +} + +export function formatDate(dateString: string | undefined): string { + if (!dateString) return ""; + + try { + const now = new Date(); + let date: Date; + + const parts = dateString.trim().split(/\s+/); + if (parts.length === 3) { + const [month, day, yearOrTime] = parts; + + if (yearOrTime.includes(":")) { + const dateStr = `${month} ${day}, ${now.getFullYear()} ${yearOrTime}:00`; + date = new Date(dateStr); + + if (date > now) { + const lastYearStr = `${month} ${day}, ${now.getFullYear() - 1} ${yearOrTime}:00`; + date = new Date(lastYearStr); + } + } else { + date = new Date(`${month} ${day}, ${yearOrTime}`); + } + } else { + date = new Date(dateString); + } + + if (isNaN(date.getTime())) { + return dateString; + } + + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); + } catch { + return dateString; + } +} + +export function sortFiles( + files: any[], + sortBy: "name" | "size" | "modified" = "name", + sortOrder: "asc" | "desc" = "asc", +): any[] { + const sorted = [...files].sort((a, b) => { + if (a.type === "directory" && b.type !== "directory") return -1; + if (a.type !== "directory" && b.type === "directory") return 1; + + let compareValue = 0; + + switch (sortBy) { + case "name": + compareValue = a.name.localeCompare(b.name); + break; + case "size": + compareValue = (a.size || 0) - (b.size || 0); + break; + case "modified": + compareValue = + new Date(a.modified || 0).getTime() - + new Date(b.modified || 0).getTime(); + break; + } + + return sortOrder === "asc" ? compareValue : -compareValue; + }); + + return sorted; +} + +export function getFileIconColor(filename: string, type: string): string { + if (type === "directory") return "#3B82F6"; + if (type === "link") return "#8B5CF6"; + + const ext = getFileExtension(filename); + + if (["js", "jsx", "ts", "tsx"].includes(ext)) return "#F59E0B"; + if (["py"].includes(ext)) return "#3B82F6"; + if (["java", "class"].includes(ext)) return "#EF4444"; + if (["c", "cpp", "h", "hpp"].includes(ext)) return "#06B6D4"; + if (["go"].includes(ext)) return "#06B6D4"; + if (["rs"].includes(ext)) return "#F97316"; + + if (["html", "htm"].includes(ext)) return "#F97316"; + if (["css", "scss", "sass", "less"].includes(ext)) return "#3B82F6"; + if (["json", "xml"].includes(ext)) return "#F59E0B"; + + if (["yml", "yaml", "toml", "ini", "conf", "cfg"].includes(ext)) + return "#8B5CF6"; + if (["env", "gitignore", "dockerignore"].includes(ext)) return "#6B7280"; + + if (["md", "txt"].includes(ext)) return "#10B981"; + if (["pdf"].includes(ext)) return "#EF4444"; + if (["doc", "docx"].includes(ext)) return "#3B82F6"; + + if (isArchiveFile(filename)) return "#8B5CF6"; + + if (isImageFile(filename)) return "#EC4899"; + + if (isVideoFile(filename)) return "#F59E0B"; + + if (["sh", "bash", "zsh", "fish"].includes(ext)) return "#10B981"; + + return "#9CA3AF"; +} + +export function breadcrumbsFromPath(path: string): string[] { + if (!path || path === "/") return ["/"]; + + const parts = path.split("/").filter((p) => p.trim() !== ""); + + const breadcrumbs: string[] = ["/"]; + + parts.forEach((part, index) => { + const cumulativeParts = parts.slice(0, index + 1); + const breadcrumbPath = "/" + cumulativeParts.join("/"); + breadcrumbs.push(breadcrumbPath); + }); + + return breadcrumbs; +} + +export function getBreadcrumbLabel(path: string): string { + if (path === "/") return "/"; + const parts = path.split("/").filter((p) => p); + return parts[parts.length - 1] || "/"; +} diff --git a/app/tabs/sessions/navigation/TabBar.tsx b/app/tabs/sessions/navigation/TabBar.tsx new file mode 100644 index 0000000..3157c14 --- /dev/null +++ b/app/tabs/sessions/navigation/TabBar.tsx @@ -0,0 +1,299 @@ +import React from "react"; +import { + View, + Text, + TouchableOpacity, + ScrollView, + TextInput, + Keyboard, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + X, + ArrowLeft, + Plus, + Minus, + ChevronDown, + ChevronUp, +} from "lucide-react-native"; +import { TerminalSession } from "@/app/contexts/TerminalSessionsContext"; +import { useRouter } from "expo-router"; +import { useKeyboard } from "@/app/contexts/KeyboardContext"; +import { useOrientation } from "@/app/utils/orientation"; +import { getTabBarHeight, getButtonSize } from "@/app/utils/responsive"; +import { + BORDERS, + BORDER_COLORS, + BACKGROUNDS, + RADIUS, +} from "@/app/constants/designTokens"; + +interface TabBarProps { + sessions: TerminalSession[]; + activeSessionId: string | null; + onTabPress: (sessionId: string) => void; + onTabClose: (sessionId: string) => void; + onAddSession?: () => void; + onToggleKeyboard?: () => void; + isCustomKeyboardVisible: boolean; + hiddenInputRef: React.RefObject; + onHideKeyboard?: () => void; + onShowKeyboard?: () => void; + keyboardIntentionallyHiddenRef: React.MutableRefObject; + activeSessionType?: "terminal" | "stats" | "filemanager"; +} + +export default function TabBar({ + sessions, + activeSessionId, + onTabPress, + onTabClose, + onAddSession, + onToggleKeyboard, + isCustomKeyboardVisible, + hiddenInputRef, + onHideKeyboard, + onShowKeyboard, + keyboardIntentionallyHiddenRef, + activeSessionType, +}: TabBarProps) { + const router = useRouter(); + const { isKeyboardVisible } = useKeyboard(); + const { isLandscape } = useOrientation(); + const insets = useSafeAreaInsets(); + + const tabBarHeight = getTabBarHeight(isLandscape); + const buttonSize = getButtonSize(isLandscape); + + const needsBottomPadding = activeSessionType !== "terminal"; + + const handleToggleSystemKeyboard = () => { + if (keyboardIntentionallyHiddenRef.current) { + onShowKeyboard?.(); + setTimeout(() => { + hiddenInputRef.current?.focus(); + }, 50); + } else { + onHideKeyboard?.(); + Keyboard.dismiss(); + } + }; + + if (sessions.length === 0) { + return null; + } + + return ( + + + + router.navigate("/hosts" as any)} + focusable={false} + className="items-center justify-center" + activeOpacity={0.7} + style={{ + width: buttonSize, + height: buttonSize, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, + backgroundColor: BACKGROUNDS.BUTTON, + borderRadius: RADIUS.BUTTON, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + marginRight: isLandscape ? 6 : 8, + }} + > + + + + + + {sessions.map((session) => { + const isActive = session.id === activeSessionId; + + return ( + onTabPress(session.id)} + focusable={false} + className="flex-row items-center" + style={{ + borderWidth: BORDERS.STANDARD, + borderColor: isActive + ? BORDER_COLORS.ACTIVE + : BORDER_COLORS.BUTTON, + backgroundColor: BACKGROUNDS.CARD, + borderRadius: RADIUS.BUTTON, + shadowColor: isActive + ? BORDER_COLORS.ACTIVE + : "transparent", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: isActive ? 0.2 : 0, + shadowRadius: 4, + elevation: isActive ? 3 : 0, + minWidth: isLandscape ? 100 : 120, + height: buttonSize, + }} + > + + + {session.title} + + + + { + e.stopPropagation(); + onTabClose(session.id); + }} + focusable={false} + className="items-center justify-center" + activeOpacity={0.7} + style={{ + width: isLandscape ? 32 : 36, + height: buttonSize, + borderLeftWidth: BORDERS.STANDARD, + borderLeftColor: isActive + ? BORDER_COLORS.ACTIVE + : BORDER_COLORS.BUTTON, + }} + > + + + + ); + })} + + + + {activeSessionType === "terminal" && !isCustomKeyboardVisible && ( + + {keyboardIntentionallyHiddenRef.current ? ( + + ) : ( + + )} + + )} + + {activeSessionType === "terminal" && ( + onToggleKeyboard?.()} + focusable={false} + className="items-center justify-center" + activeOpacity={0.7} + style={{ + width: buttonSize, + height: buttonSize, + borderWidth: BORDERS.STANDARD, + borderColor: BORDER_COLORS.BUTTON, + backgroundColor: BACKGROUNDS.BUTTON, + borderRadius: RADIUS.BUTTON, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + marginLeft: isLandscape ? 6 : 8, + }} + > + {isCustomKeyboardVisible ? ( + + ) : ( + + )} + + )} + + + {activeSessionType === "terminal" && isCustomKeyboardVisible && ( + + )} + + ); +} diff --git a/app/tabs/sessions/server-stats/ServerStats.tsx b/app/tabs/sessions/server-stats/ServerStats.tsx new file mode 100644 index 0000000..26852c8 --- /dev/null +++ b/app/tabs/sessions/server-stats/ServerStats.tsx @@ -0,0 +1,582 @@ +import React, { + useRef, + useEffect, + useState, + useCallback, + forwardRef, + useImperativeHandle, +} from "react"; +import { + View, + Text, + ScrollView, + ActivityIndicator, + RefreshControl, + TouchableOpacity, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + Cpu, + MemoryStick, + HardDrive, + Activity, + Clock, + 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"; +import { + getResponsivePadding, + getColumnCount, + getTabBarHeight, +} from "@/app/utils/responsive"; +import { + BACKGROUNDS, + BORDER_COLORS, + RADIUS, + TEXT_COLORS, +} from "@/app/constants/designTokens"; + +interface ServerStatsProps { + hostConfig: { + id: number; + name: string; + quickActions?: QuickAction[]; + }; + isVisible: boolean; + title?: string; + onClose?: () => void; +} + +export type 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); + + const padding = getResponsivePadding(isLandscape); + const columnCount = getColumnCount(width, isLandscape, 350); + const tabBarHeight = getTabBarHeight(isLandscape); + + const fetchMetrics = useCallback( + async (showLoadingSpinner = true) => { + try { + if (showLoadingSpinner) { + setIsLoading(true); + } + setError(null); + + const data = await getServerMetricsById(hostConfig.id); + setMetrics(data); + } catch (err: any) { + const errorMessage = err?.message || "Failed to fetch server metrics"; + setError(errorMessage); + if (showLoadingSpinner) { + showToast.error(errorMessage); + } + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, + [hostConfig.id], + ); + + const handleRefresh = useCallback(() => { + setIsRefreshing(true); + fetchMetrics(false); + }, [fetchMetrics]); + + useImperativeHandle( + ref, + () => ({ + refresh: handleRefresh, + }), + [handleRefresh], + ); + + useEffect(() => { + if (isVisible) { + fetchMetrics(); + + refreshIntervalRef.current = setInterval(() => { + fetchMetrics(false); + }, 5000); + } else { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + } + + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + } + }; + }, [isVisible, fetchMetrics]); + + const cardWidth = + isLandscape && columnCount > 1 ? `${100 / columnCount - 1}%` : "100%"; + + const formatUptime = (seconds: number | null): string => { + if (seconds === null || seconds === undefined) return "N/A"; + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } + }; + + const handleQuickAction = async (action: QuickAction) => { + setExecutingActions((prev) => new Set(prev).add(action.snippetId)); + showToast.info(`Executing ${action.name}...`); + + try { + const result = await executeSnippet(action.snippetId, hostConfig.id); + + if (result.success) { + showToast.success(`${action.name} completed successfully`); + } else { + showToast.error(`${action.name} failed`); + } + } 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} + + + + + + {value} + + {subtitle} + + + ); + }; + + if (!isVisible) { + return null; + } + + return ( + + {isLoading && !metrics ? ( + + + + Loading server metrics... + + + ) : error ? ( + + + + Failed to Load Metrics + + + {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" : "#22C55E", + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: RADIUS.BUTTON, + flexDirection: "row", + alignItems: "center", + gap: 8, + opacity: isExecuting ? 0.6 : 1, + }} + activeOpacity={0.7} + > + {isExecuting && ( + + )} + + {action.name} + + + ); + })} + + + )} + + 1 ? "row" : "column", + flexWrap: "wrap", + gap: 12, + }} + > + 1 ? 0 : 12, + width: cardWidth, + }} + > + + + + CPU Usage + + + + + + {typeof metrics?.cpu?.percent === "number" + ? `${metrics.cpu.percent}%` + : "N/A"} + + + {typeof metrics?.cpu?.cores === "number" + ? `${metrics.cpu.cores} cores` + : "N/A"} + + + + {metrics?.cpu?.load && ( + + + Load Average + + + + + {metrics.cpu.load[0].toFixed(2)} + + + 1 min + + + + + {metrics.cpu.load[1].toFixed(2)} + + + 5 min + + + + + {metrics.cpu.load[2].toFixed(2)} + + + 15 min + + + + + )} + + + {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", + )} + + + )} + + ); + }, +); + +export default ServerStats; diff --git a/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx b/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx new file mode 100644 index 0000000..18da3c2 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/CpuWidget.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; +import { Cpu } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; + +interface WidgetProps { + metrics: ServerMetrics | null; + 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; + + return ( + + + + CPU Usage + + + + + {cpuPercent !== null ? `${cpuPercent.toFixed(1)}%` : "N/A"} + + + {cores !== null ? `${cores} cores` : "N/A"} + + + + {load && ( + + + {load[0].toFixed(2)} + 1m + + + {load[1].toFixed(2)} + 5m + + + {load[2].toFixed(2)} + 15m + + + )} + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: "relative", + }, + header: { + flexDirection: "row", + alignItems: "center", + marginBottom: 12, + }, + title: { + color: "#ffffff", + fontSize: 16, + fontWeight: "600", + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: "700", + marginBottom: 4, + }, + subtitle: { + color: "#9CA3AF", + fontSize: 14, + }, + loadRow: { + flexDirection: "row", + justifyContent: "space-around", + marginTop: 8, + }, + loadItem: { + alignItems: "center", + }, + loadValue: { + color: "#ffffff", + fontSize: 16, + fontWeight: "600", + }, + loadLabel: { + color: "#9CA3AF", + fontSize: 12, + marginTop: 2, + }, + loadingOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.3)", + justifyContent: "center", + alignItems: "center", + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx new file mode 100644 index 0000000..e663730 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/DiskWidget.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; +import { HardDrive } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const DiskWidget: React.FC = ({ metrics, isLoading }) => { + const diskPercent = metrics?.disk?.percent ?? null; + const usedHuman = metrics?.disk?.usedHuman ?? null; + const totalHuman = metrics?.disk?.totalHuman ?? null; + + return ( + + + + Disk Usage + + + + + {diskPercent !== null ? `${diskPercent.toFixed(1)}%` : "N/A"} + + + {usedHuman !== null && totalHuman !== null + ? `${usedHuman} / ${totalHuman}` + : "N/A"} + + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: "relative", + }, + header: { + flexDirection: "row", + alignItems: "center", + marginBottom: 12, + }, + title: { + color: "#ffffff", + fontSize: 16, + fontWeight: "600", + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: "700", + marginBottom: 4, + }, + subtitle: { + color: "#9CA3AF", + fontSize: 14, + }, + loadingOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.3)", + justifyContent: "center", + alignItems: "center", + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx new file mode 100644 index 0000000..58ed401 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/MemoryWidget.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { View, Text, ActivityIndicator, StyleSheet } from "react-native"; +import { MemoryStick } from "lucide-react-native"; +import { ServerMetrics } from "@/types"; +import { + BORDERS, + BORDER_COLORS, + RADIUS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; + +interface WidgetProps { + metrics: ServerMetrics | null; + isLoading?: boolean; +} + +export const MemoryWidget: React.FC = ({ metrics, isLoading }) => { + const memoryPercent = metrics?.memory?.percent ?? null; + const usedGiB = metrics?.memory?.usedGiB ?? null; + const totalGiB = metrics?.memory?.totalGiB ?? null; + + return ( + + + + Memory Usage + + + + + {memoryPercent !== null ? `${memoryPercent.toFixed(1)}%` : "N/A"} + + + {usedGiB !== null && totalGiB !== null + ? `${usedGiB.toFixed(2)} / ${totalGiB.toFixed(2)} GiB` + : "N/A"} + + + + {isLoading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + widgetCard: { + padding: 16, + position: "relative", + }, + header: { + flexDirection: "row", + alignItems: "center", + marginBottom: 12, + }, + title: { + color: "#ffffff", + fontSize: 16, + fontWeight: "600", + marginLeft: 8, + }, + metricRow: { + marginBottom: 12, + }, + value: { + fontSize: 32, + fontWeight: "700", + marginBottom: 4, + }, + subtitle: { + color: "#9CA3AF", + fontSize: 14, + }, + loadingOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.3)", + justifyContent: "center", + alignItems: "center", + borderRadius: 12, + }, +}); diff --git a/app/tabs/sessions/server-stats/widgets/index.ts b/app/tabs/sessions/server-stats/widgets/index.ts new file mode 100644 index 0000000..3707803 --- /dev/null +++ b/app/tabs/sessions/server-stats/widgets/index.ts @@ -0,0 +1,3 @@ +export { CpuWidget } from "./CpuWidget"; +export { MemoryWidget } from "./MemoryWidget"; +export { DiskWidget } from "./DiskWidget"; diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx new file mode 100644 index 0000000..949a6b0 --- /dev/null +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -0,0 +1,1437 @@ +import React, { + useRef, + useEffect, + useState, + useCallback, + forwardRef, + useImperativeHandle, +} from "react"; +import { + View, + Text, + ActivityIndicator, + Dimensions, + TouchableWithoutFeedback, + Keyboard, + TextInput, + TouchableOpacity, +} from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { WebView } from "react-native-webview"; +import { + getCurrentServerUrl, + getCookie, + logActivity, + getSnippets, +} from "../../../main-axios"; +import { showToast } from "../../../utils/toast"; +import { useTerminalCustomization } from "../../../contexts/TerminalCustomizationContext"; +import { BACKGROUNDS, BORDER_COLORS } from "../../../constants/designTokens"; +import { TOTPDialog, SSHAuthDialog } from "@/app/tabs/dialogs"; +import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes"; +import { MOBILE_DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-config"; +import type { TerminalConfig } from "@/types"; + +interface TerminalProps { + hostConfig: { + id: number; + name: string; + ip: string; + port: number; + username: string; + authType: "password" | "key" | "credential" | "none"; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + credentialId?: number; + terminalConfig?: Partial; + }; + isVisible: boolean; + title?: string; + onClose?: () => void; + onBackgroundColorChange?: (color: string) => void; +} + +export type TerminalHandle = { + sendInput: (data: string) => void; + fit: () => void; + isDialogOpen: () => boolean; + notifyBackgrounded: () => void; + notifyForegrounded: () => void; + scrollToBottom: () => void; + isSelecting: () => boolean; +}; + +const TerminalComponent = forwardRef( + ( + { + hostConfig, + isVisible, + title = "Terminal", + onClose, + onBackgroundColorChange, + }, + ref, + ) => { + const webViewRef = useRef(null); + const { config } = useTerminalCustomization(); + const [webViewKey, setWebViewKey] = useState(0); + const [screenDimensions, setScreenDimensions] = useState( + Dimensions.get("window"), + ); + type ConnectionState = + | "connecting" + | "connected" + | "reconnecting" + | "disconnected" + | "failed"; + const [connectionState, setConnectionState] = + useState("connecting"); + const [retryCount, setRetryCount] = useState(0); + const [hasReceivedData, setHasReceivedData] = useState(false); + const [htmlContent, setHtmlContent] = useState(""); + const [currentHostId, setCurrentHostId] = useState(null); + const [terminalBackgroundColor, setTerminalBackgroundColor] = + useState("#09090b"); + const connectionTimeoutRef = useRef | null>( + null, + ); + + const [totpRequired, setTotpRequired] = useState(false); + const [totpPrompt, setTotpPrompt] = useState(""); + const [isPasswordPrompt, setIsPasswordPrompt] = useState(false); + const [showAuthDialog, setShowAuthDialog] = useState(false); + const [authDialogReason, setAuthDialogReason] = useState< + "no_keyboard" | "auth_failed" | "timeout" + >("auth_failed"); + const [isSelecting, setIsSelecting] = useState(false); + + useEffect(() => { + const subscription = Dimensions.addEventListener( + "change", + ({ window }) => { + setScreenDimensions(window); + }, + ); + + return () => subscription?.remove(); + }, []); + + const handleConnectionFailure = useCallback( + (errorMessage: string) => { + showToast.error(errorMessage); + setConnectionState("failed"); + if (onClose) { + onClose(); + } + }, + [onClose], + ); + + const getWebSocketUrl = async () => { + const serverUrl = getCurrentServerUrl(); + + if (!serverUrl) { + showToast.error( + "No server URL found - please configure a server first", + ); + return null; + } + + const jwtToken = await getCookie("jwt"); + if (!jwtToken || jwtToken.trim() === "") { + showToast.error("Authentication required - please log in again"); + return null; + } + + const wsProtocol = serverUrl.startsWith("https://") ? "wss://" : "ws://"; + const wsHost = serverUrl.replace(/^https?:\/\//, ""); + const cleanHost = wsHost.replace(/\/$/, ""); + const wsUrl = `${wsProtocol}${cleanHost}/ssh/websocket/?token=${encodeURIComponent(jwtToken)}`; + + return wsUrl; + }; + + const generateHTML = useCallback(async () => { + const wsUrl = await getWebSocketUrl(); + const { width, height } = screenDimensions; + + if (!wsUrl) { + return ` + + + + + Terminal + + +
+

No Server Configured

+

Please configure a server first

+
+ +`; + } + + const terminalConfig: Partial = { + ...MOBILE_DEFAULT_TERMINAL_CONFIG, + ...config, + ...hostConfig.terminalConfig, + }; + + const baseFontSize = config.fontSize || 16; + const charWidth = baseFontSize * 0.6; + const lineHeight = baseFontSize * 1.2; + const terminalWidth = Math.floor(width / charWidth); + const terminalHeight = Math.floor(height / lineHeight); + + const themeName = terminalConfig.theme || "termix"; + const themeColors = + TERMINAL_THEMES[themeName]?.colors || TERMINAL_THEMES.termix.colors; + + const bgColor = themeColors.background; + setTerminalBackgroundColor(bgColor); + if (onBackgroundColorChange) { + onBackgroundColorChange(bgColor); + } + + const fontConfig = TERMINAL_FONTS.find( + (f) => f.value === terminalConfig.fontFamily, + ); + const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; + + return ` + + + + + + Terminal + + + + + + +
+ + + + + `; + }, [hostConfig, screenDimensions, config.fontSize]); + + useEffect(() => { + const updateHtml = async () => { + const html = await generateHTML(); + setHtmlContent(html); + }; + updateHtml(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleTotpSubmit = useCallback( + (code: string) => { + const responseType = isPasswordPrompt + ? "password_response" + : "totp_response"; + + webViewRef.current?.injectJavaScript(` + (function() { + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: '${responseType}', + data: { code: ${JSON.stringify(code)} } + })); + } + })(); + true; + `); + + setTotpRequired(false); + setTotpPrompt(""); + setIsPasswordPrompt(false); + setIsConnecting(true); + setShowConnectingOverlay(true); + }, + [isPasswordPrompt], + ); + + const handleAuthDialogSubmit = useCallback( + (credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) => { + const updatedHostConfig = { + ...hostConfig, + password: credentials.password, + key: credentials.sshKey, + keyPassword: credentials.keyPassword, + authType: credentials.password ? "password" : "key", + }; + + const messageData = { + password: credentials.password, + sshKey: credentials.sshKey, + keyPassword: credentials.keyPassword, + hostConfig: updatedHostConfig, + }; + + webViewRef.current?.injectJavaScript(` + (function() { + if (window.ws && window.ws.readyState === WebSocket.OPEN && window.terminal) { + const data = ${JSON.stringify(messageData)}; + data.cols = window.terminal.cols; + data.rows = window.terminal.rows; + + window.ws.send(JSON.stringify({ + type: 'reconnect_with_credentials', + data: data + })); + } + })(); + true; + `); + setShowAuthDialog(false); + setIsConnecting(true); + }, + [hostConfig], + ); + + const handlePostConnectionSetup = useCallback(async () => { + const terminalConfig: Partial = { + ...MOBILE_DEFAULT_TERMINAL_CONFIG, + ...config, + ...hostConfig.terminalConfig, + }; + + setTimeout(async () => { + if (terminalConfig.environmentVariables?.length) { + terminalConfig.environmentVariables.forEach((envVar, index) => { + setTimeout( + () => { + const key = envVar.key.replace(/'/g, "\\'"); + const value = envVar.value.replace(/'/g, "\\'"); + webViewRef.current?.injectJavaScript(` + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: 'input', + data: 'export ${key}="${value}"\\n' + })); + } + true; + `); + }, + 100 * (index + 1), + ); + }); + } + + if (terminalConfig.startupSnippetId) { + const snippetDelay = + 100 * (terminalConfig.environmentVariables?.length || 0) + 200; + setTimeout(async () => { + try { + const snippets = await getSnippets(); + const snippet = snippets.find( + (s) => s.id === terminalConfig.startupSnippetId, + ); + if (snippet) { + const content = snippet.content.replace(/'/g, "\\'"); + webViewRef.current?.injectJavaScript(` + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: 'input', + data: '${content}\\n' + })); + } + true; + `); + } + } catch (err) { + console.warn("Failed to execute startup snippet:", err); + } + }, snippetDelay); + } + + if (terminalConfig.autoMosh && terminalConfig.moshCommand) { + const moshDelay = + 100 * (terminalConfig.environmentVariables?.length || 0) + + (terminalConfig.startupSnippetId ? 400 : 200); + setTimeout(() => { + const moshCommand = terminalConfig.moshCommand!.replace( + /'/g, + "\\'", + ); + webViewRef.current?.injectJavaScript(` + if (window.ws && window.ws.readyState === WebSocket.OPEN) { + window.ws.send(JSON.stringify({ + type: 'input', + data: '${moshCommand}\\n' + })); + } + true; + `); + }, moshDelay); + } + }, 500); + }, [config, hostConfig.terminalConfig]); + + const handleWebViewMessage = useCallback( + (event: any) => { + try { + const message = JSON.parse(event.nativeEvent.data); + + switch (message.type) { + case "connecting": + setConnectionState( + message.data.retryCount > 0 ? "reconnecting" : "connecting", + ); + setRetryCount(message.data.retryCount); + break; + + case "connected": + setConnectionState("connected"); + setRetryCount(0); + setHasReceivedData(false); + logActivity("terminal", hostConfig.id, hostConfig.name).catch( + () => {}, + ); + break; + + case "totpRequired": + setTotpPrompt(message.data.prompt); + setIsPasswordPrompt(message.data.isPassword); + setTotpRequired(true); + break; + + case "authDialogNeeded": + setAuthDialogReason(message.data.reason); + setShowAuthDialog(true); + setConnectionState("disconnected"); + break; + + case "setupPostConnection": + handlePostConnectionSetup(); + break; + + case "dataReceived": + setHasReceivedData(true); + break; + + case "disconnected": + setConnectionState("disconnected"); + showToast.warning(`Disconnected from ${message.data.hostName}`); + if (onClose) onClose(); + break; + + case "connectionFailed": + setConnectionState("failed"); + handleConnectionFailure( + `${message.data.hostName}: ${message.data.message}`, + ); + break; + + case "backgrounded": + setConnectionState("disconnected"); + break; + + case "foregrounded": + setConnectionState("reconnecting"); + break; + + case "selectionStart": + setIsSelecting(true); + break; + + case "selectionEnd": + setIsSelecting(false); + break; + + case "connectionStatus": + break; + } + } catch (error) { + console.error("[Terminal] Error parsing WebView message:", error); + } + }, + [ + handleConnectionFailure, + onClose, + hostConfig.id, + handlePostConnectionSetup, + ], + ); + + useImperativeHandle( + ref, + () => ({ + sendInput: (data: string) => { + try { + const escaped = JSON.stringify(data); + webViewRef.current?.injectJavaScript( + `window.nativeInput(${escaped}); true;`, + ); + } catch (e) {} + }, + fit: () => { + try { + webViewRef.current?.injectJavaScript( + `window.nativeFit && window.nativeFit(); true;`, + ); + } catch (e) {} + }, + isDialogOpen: () => { + return totpRequired || showAuthDialog; + }, + notifyBackgrounded: () => { + try { + webViewRef.current?.injectJavaScript(` + window.notifyBackgrounded && window.notifyBackgrounded(); + true; + `); + } catch (e) {} + }, + notifyForegrounded: () => { + try { + webViewRef.current?.injectJavaScript(` + window.notifyForegrounded && window.notifyForegrounded(); + true; + `); + } catch (e) {} + }, + scrollToBottom: () => { + try { + webViewRef.current?.injectJavaScript(` + window.resetScroll && window.resetScroll(); + true; + `); + } catch (e) {} + }, + isSelecting: () => { + return isSelecting; + }, + }), + [totpRequired, showAuthDialog, isSelecting], + ); + + useEffect(() => { + if (hostConfig.id !== currentHostId) { + setCurrentHostId(hostConfig.id); + setWebViewKey((prev) => prev + 1); + setConnectionState("connecting"); + setHasReceivedData(false); + setRetryCount(0); + + const updateHtml = async () => { + const html = await generateHTML(); + setHtmlContent(html); + }; + updateHtml(); + } + }, [hostConfig.id, currentHostId]); + + useEffect(() => { + return () => { + webViewRef.current?.injectJavaScript(` + (function() { + try { + clearAllTimeouts(); + stopPingInterval(); + if (window.ws) { + window.ws.close(1000, 'Component unmounted'); + window.ws = null; + } + } catch(e) { + console.error('[CLEANUP] Error:', e); + } + })(); + true; + `); + + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + }; + }, []); + + const focusTerminal = useCallback(() => {}, []); + + return ( + + + + {}} + scrollEventThrottle={16} + onMessage={handleWebViewMessage} + onError={(syntheticEvent) => { + const { nativeEvent } = syntheticEvent; + handleConnectionFailure( + `WebView error: ${nativeEvent.description}`, + ); + }} + onHttpError={(syntheticEvent) => { + const { nativeEvent } = syntheticEvent; + handleConnectionFailure( + `WebView HTTP error: ${nativeEvent.statusCode}`, + ); + }} + scrollEnabled={true} + overScrollMode="never" + bounces={false} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + nestedScrollEnabled={false} + textZoom={100} + setSupportMultipleWindows={false} + /> + + + {(connectionState === "connecting" || + connectionState === "reconnecting") && ( + + + + + {connectionState === "reconnecting" + ? "Reconnecting..." + : "Connecting..."} + + + {hostConfig.name} • {hostConfig.ip} + + {retryCount > 0 && ( + + + Retry {retryCount}/3 + + + )} + + + )} + + + { + setTotpRequired(false); + setTotpPrompt(""); + setIsPasswordPrompt(false); + if (onClose) onClose(); + }} + prompt={totpPrompt} + isPasswordPrompt={isPasswordPrompt} + /> + + { + setShowAuthDialog(false); + if (onClose) onClose(); + }} + hostInfo={{ + name: hostConfig.name, + ip: hostConfig.ip, + port: hostConfig.port, + username: hostConfig.username, + }} + reason={authDialogReason} + /> + + ); + }, +); + +TerminalComponent.displayName = "Terminal"; + +export { TerminalComponent as Terminal }; +export default TerminalComponent; diff --git a/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx b/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx new file mode 100644 index 0000000..b04c80f --- /dev/null +++ b/app/tabs/sessions/terminal/keyboard/BottomToolbar.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import { View, Text, TouchableOpacity } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { TerminalHandle } from "../Terminal"; +import CustomKeyboard from "./CustomKeyboard"; +import SnippetsBar from "./SnippetsBar"; +import { + BORDERS, + BORDER_COLORS, + BACKGROUNDS, +} from "@/app/constants/designTokens"; + +type ToolbarMode = "keyboard" | "snippets"; + +interface BottomToolbarProps { + terminalRef: React.RefObject; + isVisible: boolean; + keyboardHeight: number; + isKeyboardIntentionallyHidden?: boolean; +} + +export default function BottomToolbar({ + terminalRef, + isVisible, + keyboardHeight, + isKeyboardIntentionallyHidden = false, +}: BottomToolbarProps) { + const [mode, setMode] = useState("keyboard"); + const insets = useSafeAreaInsets(); + + if (!isVisible) return null; + + const safeKeyboardHeight = Math.max(200, Math.min(keyboardHeight, 500)); + + const tabs: { id: ToolbarMode; label: string }[] = [ + { id: "keyboard", label: "KEYBOARD" }, + { id: "snippets", label: "SNIPPETS" }, + ]; + + const TAB_BAR_HEIGHT = 36; + + return ( + + + {tabs.map((tab, index) => ( + setMode(tab.id)} + style={{ + borderRightWidth: + index !== tabs.length - 1 ? BORDERS.STANDARD : 0, + borderRightColor: BORDER_COLORS.SECONDARY, + }} + > + + {tab.label} + + {mode === tab.id && ( + + )} + + ))} + + + + {mode === "keyboard" && ( + + )} + + {mode === "snippets" && ( + + )} + + + ); +} diff --git a/app/Tabs/Sessions/CustomKeyboard.tsx b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx similarity index 66% rename from app/Tabs/Sessions/CustomKeyboard.tsx rename to app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx index 7f408b0..f9ca110 100644 --- a/app/Tabs/Sessions/CustomKeyboard.tsx +++ b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx @@ -1,9 +1,11 @@ import React from "react"; -import { View, StyleSheet, ScrollView, Clipboard, Text } from "react-native"; -import { TerminalHandle } from "./Terminal"; +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"; interface CustomKeyboardProps { terminalRef: React.RefObject; @@ -40,7 +42,7 @@ export default function CustomKeyboard({ sendKey(" "); break; case "backspace": - sendKey("\x08"); + sendKey("\x7f"); break; case "escape": sendKey("\x1b"); @@ -124,28 +126,32 @@ export default function CustomKeyboard({ return baseStyle; }; + const safeKeyboardHeight = Math.max(200, Math.min(keyboardHeight, 500)); + return ( - + {visibleRows.map((row, rowIndex) => ( {row.label && ( - - {row.label} + + + {row.label} + )} {row.keys.map((key, keyIndex) => ( )} ))} {config.settings.showHints && !isKeyboardIntentionallyHidden && ( - - Customize in Settings + + + Customize in Settings + )} ); } - -const styles = StyleSheet.create({ - keyboard: { - backgroundColor: "#0e0e10", - borderTopWidth: 1.5, - borderTopColor: "#303032", - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 8, - paddingVertical: 8, - flexGrow: 1, - }, - rowLabelContainer: { - marginBottom: 4, - marginTop: 4, - }, - rowLabel: { - fontSize: 11, - color: "#888", - fontWeight: "600", - textTransform: "uppercase", - letterSpacing: 0.5, - }, - keyRow: { - flexDirection: "row", - alignItems: "center", - marginBottom: 0, - flexWrap: "wrap", - }, - numberRow: { - flexDirection: "row", - alignItems: "center", - marginBottom: 0, - flexWrap: "nowrap", - }, - compactRow: { - marginBottom: -2, - }, - separator: { - height: 1, - backgroundColor: "#404040", - marginVertical: 8, - marginHorizontal: 0, - }, - compactSeparator: { - marginVertical: 4, - }, - hintContainer: { - paddingHorizontal: 8, - paddingTop: 8, - paddingBottom: 4, - alignItems: "center", - }, - hintText: { - fontSize: 10, - color: "#666", - fontStyle: "italic", - }, -}); diff --git a/app/Tabs/Sessions/KeyDefinitions.ts b/app/tabs/sessions/terminal/keyboard/KeyDefinitions.ts similarity index 100% rename from app/Tabs/Sessions/KeyDefinitions.ts rename to app/tabs/sessions/terminal/keyboard/KeyDefinitions.ts diff --git a/app/Tabs/Sessions/KeyboardBar.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx similarity index 67% rename from app/Tabs/Sessions/KeyboardBar.tsx rename to app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx index 092c65a..6c0b12a 100644 --- a/app/Tabs/Sessions/KeyboardBar.tsx +++ b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx @@ -1,17 +1,17 @@ import React, { useState, useEffect } from "react"; -import { - View, - ScrollView, - StyleSheet, - Text, - Clipboard, - Platform, -} from "react-native"; -import { TerminalHandle } from "./Terminal"; +import { View, ScrollView, Text, Platform } 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"; interface KeyboardBarProps { terminalRef: React.RefObject; @@ -28,6 +28,7 @@ export default function KeyboardBar({ }: KeyboardBarProps) { const { config } = useKeyboardCustomization(); const { keyboardHeight, isKeyboardVisible } = useKeyboard(); + const { isLandscape } = useOrientation(); const [ctrlPressed, setCtrlPressed] = useState(false); const [altPressed, setAltPressed] = useState(false); @@ -125,58 +126,47 @@ export default function KeyboardBar({ const hasPinnedKeys = pinnedKeys.length > 0; return ( - - + - {hasPinnedKeys && ( - <> - {pinnedKeys.map((key, index) => renderKey(key, index))} - - - )} + + {hasPinnedKeys && ( + <> + {pinnedKeys.map((key, index) => renderKey(key, index))} + + + )} - {keys.map((key, index) => renderKey(key, index))} - + {keys.map((key, index) => renderKey(key, index))} + + + ); } - -const styles = StyleSheet.create({ - keyboardBar: { - backgroundColor: "#0e0e10", - borderTopWidth: 1.5, - borderTopColor: "#303032", - }, - scrollContent: { - paddingHorizontal: 8, - paddingVertical: 8, - alignItems: "center", - gap: 6, - }, - separator: { - width: 1, - height: 30, - backgroundColor: "#404040", - marginHorizontal: 8, - }, - hintContainer: { - paddingHorizontal: 12, - paddingBottom: 2, - paddingTop: 0, - alignItems: "center", - }, - hintText: { - fontSize: 10, - color: "#666", - fontStyle: "italic", - }, -}); diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx new file mode 100644 index 0000000..95672c3 --- /dev/null +++ b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { TouchableOpacity, Text } from "react-native"; +import * as Haptics from "expo-haptics"; +import { KeySize } from "@/types/keyboard"; +import { + BACKGROUNDS, + BORDER_COLORS, + RADIUS, +} from "@/app/constants/designTokens"; + +interface KeyboardKeyProps { + label: string; + onPress: () => void; + style?: any; + textStyle?: any; + isActive?: boolean; + isModifier?: boolean; + keySize?: KeySize; + hapticFeedback?: boolean; + onLongPress?: () => void; +} + +export default function KeyboardKey({ + label, + onPress, + style = {}, + textStyle = {}, + isActive = false, + isModifier = false, + keySize = "medium", + hapticFeedback = false, + onLongPress, +}: KeyboardKeyProps) { + const handlePress = () => { + if (hapticFeedback) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + onPress(); + }; + + const handleLongPress = () => { + if (hapticFeedback) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + if (onLongPress) { + onLongPress(); + } + }; + + const getSizeClass = () => { + switch (keySize) { + case "small": + return "px-1.5 py-1.5 min-w-[32px] min-h-[32px]"; + case "large": + return "px-2.5 py-2.5 min-w-[42px] min-h-[42px]"; + case "medium": + default: + return "px-2 py-2 min-w-[36px] min-h-[36px]"; + } + }; + + const getTextSizeClass = () => { + switch (keySize) { + case "small": + return "text-[11px]"; + case "large": + return "text-sm"; + case "medium": + default: + return "text-xs"; + } + }; + + return ( + + + {label} + + + ); +} diff --git a/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx b/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx new file mode 100644 index 0000000..c0c6fde --- /dev/null +++ b/app/tabs/sessions/terminal/keyboard/SnippetsBar.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect } from "react"; +import { + View, + Text, + ScrollView, + TouchableOpacity, + ActivityIndicator, +} from "react-native"; +import { TerminalHandle } from "@/app/tabs/sessions/terminal/Terminal"; +import { getSnippets, getSnippetFolders } from "@/app/main-axios"; +import { showToast } from "@/app/utils/toast"; +import { BORDER_COLORS, RADIUS } from "@/app/constants/designTokens"; + +interface Snippet { + id: number; + name: string; + content: string; + description?: string | null; + folder: string | null; + order: number; + userId: string; + createdAt: string; + updatedAt: string; +} + +interface SnippetFolder { + id: number; + name: string; + color: string | null; + icon: string | null; + userId: string; + createdAt: string; + updatedAt: string; +} + +interface SnippetsBarProps { + terminalRef: React.RefObject; + isVisible: boolean; + height: number; +} + +export default function SnippetsBar({ + terminalRef, + isVisible, + height, +}: SnippetsBarProps) { + const [snippets, setSnippets] = useState([]); + const [folders, setFolders] = useState([]); + const [collapsedFolders, setCollapsedFolders] = useState>( + new Set(), + ); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (isVisible) { + loadSnippets(); + } + }, [isVisible]); + + const loadSnippets = async () => { + try { + setLoading(true); + const [snippetsData, foldersData] = await Promise.all([ + getSnippets().catch((err) => { + console.error("Failed to fetch snippets:", err); + return []; + }), + getSnippetFolders().catch((err) => { + console.error("Failed to fetch snippet folders:", err); + return []; + }), + ]); + + const snippetsArray = Array.isArray(snippetsData) ? snippetsData : []; + const foldersArray = Array.isArray(foldersData) ? foldersData : []; + + setSnippets( + snippetsArray.sort((a: Snippet, b: Snippet) => a.order - b.order), + ); + setFolders(foldersArray); + } catch (error) { + console.error("Failed to load snippets:", error); + showToast.error("Failed to load snippets"); + setSnippets([]); + setFolders([]); + } finally { + setLoading(false); + } + }; + + const executeSnippet = (snippet: Snippet) => { + if (terminalRef.current) { + terminalRef.current.sendInput(snippet.content + "\n"); + showToast.success(`Executed: ${snippet.name}`); + } + }; + + const toggleFolder = (folderId: number) => { + setCollapsedFolders((prev) => { + const newSet = new Set(prev); + if (newSet.has(folderId)) { + newSet.delete(folderId); + } else { + newSet.add(folderId); + } + return newSet; + }); + }; + + const getSnippetsInFolder = (folderName: string | null) => { + return snippets.filter((s) => s.folder === folderName); + }; + + if (!isVisible) return null; + + const unfolderedSnippets = getSnippetsInFolder(null); + + return ( + + {loading ? ( + + + + Loading snippets... + + + ) : ( + + {unfolderedSnippets.length > 0 && ( + + toggleFolder(0)} + > + + + Uncategorized + + + ({unfolderedSnippets.length}) + + + + {collapsedFolders.has(0) ? "▶" : "▼"} + + + + {!collapsedFolders.has(0) && + unfolderedSnippets.map((snippet) => ( + executeSnippet(snippet)} + > + + {snippet.name} + + + ))} + + )} + + {folders.map((folder) => { + const folderSnippets = getSnippetsInFolder(folder.name); + const isCollapsed = collapsedFolders.has(folder.id); + + return ( + + toggleFolder(folder.id)} + > + + + {folder.name} + + + ({folderSnippets.length}) + + + + {isCollapsed ? "▶" : "▼"} + + + + {!isCollapsed && + folderSnippets.map((snippet) => ( + executeSnippet(snippet)} + > + + {snippet.name} + + + ))} + + ); + })} + + {snippets.length === 0 && ( + + + No snippets yet + + + Create snippets in the Termix web/desktop version + + + )} + + )} + + ); +} diff --git a/app/tabs/sessions/tunnel/TunnelCard.tsx b/app/tabs/sessions/tunnel/TunnelCard.tsx new file mode 100644 index 0000000..509cb81 --- /dev/null +++ b/app/tabs/sessions/tunnel/TunnelCard.tsx @@ -0,0 +1,335 @@ +import React from "react"; +import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native"; +import { + CheckCircle, + Loader2, + AlertCircle, + Clock, + Circle, + Play, + Square, + X, + RotateCcw, +} from "lucide-react-native"; +import type { TunnelCardProps } from "@/types"; +import { + BACKGROUNDS, + BORDER_COLORS, + RADIUS, + TEXT_COLORS, +} from "@/app/constants/designTokens"; + +const TunnelCard: React.FC = ({ + tunnel, + tunnelName, + status, + isLoading, + onAction, +}) => { + const getStatusInfo = () => { + if (!status) { + return { + label: "Disconnected", + color: "#6b7280", + icon: Circle, + bgColor: "rgba(107, 114, 128, 0.1)", + }; + } + + const statusUpper = status.status?.toUpperCase() || "DISCONNECTED"; + + switch (statusUpper) { + case "CONNECTED": + return { + label: "Connected", + color: "#10b981", + icon: CheckCircle, + bgColor: "rgba(16, 185, 129, 0.1)", + }; + case "CONNECTING": + return { + label: "Connecting", + color: "#3b82f6", + icon: Loader2, + bgColor: "rgba(59, 130, 246, 0.1)", + }; + case "DISCONNECTING": + return { + label: "Disconnecting", + color: "#f59e0b", + icon: Loader2, + bgColor: "rgba(245, 158, 11, 0.1)", + }; + case "ERROR": + case "FAILED": + return { + label: "Error", + color: "#ef4444", + icon: AlertCircle, + bgColor: "rgba(239, 68, 68, 0.1)", + }; + case "RETRYING": + return { + label: `Retrying (${status.retryCount || 0}/${status.maxRetries || 0})`, + color: "#f59e0b", + icon: RotateCcw, + bgColor: "rgba(245, 158, 11, 0.1)", + }; + case "WAITING": + return { + label: status.nextRetryIn + ? `Waiting (${Math.ceil(status.nextRetryIn / 1000)}s)` + : "Waiting", + color: "#8b5cf6", + icon: Clock, + bgColor: "rgba(139, 92, 246, 0.1)", + }; + default: + return { + label: statusUpper, + color: "#6b7280", + icon: Circle, + bgColor: "rgba(107, 114, 128, 0.1)", + }; + } + }; + + const statusInfo = getStatusInfo(); + const StatusIcon = statusInfo.icon; + + const statusValue = status?.status?.toUpperCase() || "DISCONNECTED"; + const canConnect = + !status || + statusValue === "DISCONNECTED" || + statusValue === "ERROR" || + statusValue === "FAILED"; + const canDisconnect = statusValue === "CONNECTED"; + const canCancel = + statusValue === "CONNECTING" || + statusValue === "RETRYING" || + statusValue === "WAITING"; + + const portMapping = `${tunnel.sourcePort} → ${tunnel.endpointHost}:${tunnel.endpointPort}`; + + return ( + + + + + Tunnel + + + + + + {statusInfo.label} + + + + + + + Port Mapping + + + {portMapping} + + + + {(statusValue === "ERROR" || statusValue === "FAILED") && + status?.reason && ( + + + {status.reason} + + + )} + + + {canConnect && ( + onAction("connect")} + disabled={isLoading} + activeOpacity={0.7} + > + {isLoading ? ( + + ) : ( + <> + + + Connect + + + )} + + )} + + {canDisconnect && ( + onAction("disconnect")} + disabled={isLoading} + activeOpacity={0.7} + > + {isLoading ? ( + + ) : ( + <> + + + Disconnect + + + )} + + )} + + {canCancel && ( + onAction("cancel")} + disabled={isLoading} + activeOpacity={0.7} + > + {isLoading ? ( + + ) : ( + <> + + + Cancel + + + )} + + )} + + + ); +}; + +export default TunnelCard; diff --git a/app/tabs/sessions/tunnel/TunnelManager.tsx b/app/tabs/sessions/tunnel/TunnelManager.tsx new file mode 100644 index 0000000..71fa866 --- /dev/null +++ b/app/tabs/sessions/tunnel/TunnelManager.tsx @@ -0,0 +1,447 @@ +import React, { + useRef, + useEffect, + useState, + useCallback, + forwardRef, + useImperativeHandle, +} from "react"; +import { + View, + Text, + ScrollView, + ActivityIndicator, + RefreshControl, + TouchableOpacity, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Activity } from "lucide-react-native"; +import { + getTunnelStatuses, + connectTunnel, + disconnectTunnel, + cancelTunnel, + getSSHHosts, +} from "../../../main-axios"; +import { showToast } from "../../../utils/toast"; +import type { + TunnelStatus, + SSHHost, + TunnelConnection, + TunnelSessionProps, +} from "../../../../types"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding, getColumnCount } from "@/app/utils/responsive"; +import { + BACKGROUNDS, + BORDER_COLORS, + RADIUS, + TEXT_COLORS, +} from "@/app/constants/designTokens"; +import TunnelCard from "@/app/tabs/sessions/tunnel/TunnelCard"; + +export type TunnelManagerHandle = { + refresh: () => void; +}; + +export const TunnelManager = forwardRef< + TunnelManagerHandle, + TunnelSessionProps +>(({ hostConfig, isVisible, title = "Manage Tunnels", onClose }, ref) => { + const insets = useSafeAreaInsets(); + const { width, isLandscape } = useOrientation(); + const [tunnelStatuses, setTunnelStatuses] = useState< + Record + >({}); + const [loadingTunnels, setLoadingTunnels] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [allHosts, setAllHosts] = useState([]); + const [currentHostConfig, setCurrentHostConfig] = useState(hostConfig); + const refreshIntervalRef = useRef(null); + + const padding = getResponsivePadding(isLandscape); + const columnCount = getColumnCount(width, isLandscape, 350); + + const fetchTunnelStatuses = useCallback(async (showLoadingSpinner = true) => { + try { + if (showLoadingSpinner) { + setIsLoading(true); + } + setError(null); + + const statuses = await getTunnelStatuses(); + setTunnelStatuses(statuses); + } catch (err: any) { + const errorMessage = err?.message || "Failed to fetch tunnel statuses"; + setError(errorMessage); + if (showLoadingSpinner) { + showToast.error(errorMessage); + } + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, []); + + const fetchAllHosts = useCallback(async () => { + try { + const hosts = await getSSHHosts(); + setAllHosts(hosts); + + const updatedHost = hosts.find((h) => h.id === hostConfig.id); + if (updatedHost) { + setCurrentHostConfig({ + id: updatedHost.id, + name: updatedHost.name, + enableTunnel: updatedHost.enableTunnel, + tunnelConnections: updatedHost.tunnelConnections, + }); + } + } catch (err: any) { + console.error("Failed to fetch hosts for tunnel endpoint lookup:", err); + } + }, [hostConfig.id]); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + await Promise.all([fetchTunnelStatuses(false), fetchAllHosts()]); + setIsRefreshing(false); + }, [fetchTunnelStatuses, fetchAllHosts]); + + useImperativeHandle( + ref, + () => ({ + refresh: handleRefresh, + }), + [handleRefresh], + ); + + useEffect(() => { + if (isVisible) { + fetchTunnelStatuses(); + fetchAllHosts(); + + refreshIntervalRef.current = setInterval(() => { + fetchTunnelStatuses(false); + }, 5000); + } else { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + } + + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + } + }; + }, [isVisible, fetchTunnelStatuses, fetchAllHosts]); + + const handleTunnelAction = async ( + action: "connect" | "disconnect" | "cancel", + tunnelIndex: number, + ) => { + const tunnel = currentHostConfig.tunnelConnections[tunnelIndex]; + const tunnelName = `${currentHostConfig.name || `${currentHostConfig.id}`}_${tunnel.sourcePort}_${tunnel.endpointHost}_${tunnel.endpointPort}`; + + setLoadingTunnels((prev) => new Set(prev).add(tunnelName)); + + try { + if (action === "connect") { + const endpointHost = allHosts.find( + (h) => + h.name === tunnel.endpointHost || + `${h.username}@${h.ip}` === tunnel.endpointHost, + ); + + if (!endpointHost) { + throw new Error(`Endpoint host not found: ${tunnel.endpointHost}`); + } + + const sourceHost: Partial = { + id: currentHostConfig.id, + name: currentHostConfig.name, + ip: "", + port: 0, + username: "", + authType: "none", + folder: "", + tags: [], + pin: false, + enableTerminal: true, + enableTunnel: true, + enableFileManager: true, + defaultPath: "", + tunnelConnections: currentHostConfig.tunnelConnections, + createdAt: "", + updatedAt: "", + }; + + const fullHost = allHosts.find((h) => h.id === currentHostConfig.id); + if (!fullHost) { + throw new Error("Source host not found"); + } + + const tunnelConfig = { + name: tunnelName, + hostName: fullHost.name || `${fullHost.username}@${fullHost.ip}`, + sourceIP: fullHost.ip, + sourceSSHPort: fullHost.port, + sourceUsername: fullHost.username, + sourcePassword: + fullHost.authType === "password" ? fullHost.password : undefined, + sourceAuthMethod: fullHost.authType, + sourceSSHKey: fullHost.authType === "key" ? fullHost.key : undefined, + sourceKeyPassword: + fullHost.authType === "key" ? fullHost.keyPassword : undefined, + sourceKeyType: + fullHost.authType === "key" ? fullHost.keyType : undefined, + sourceCredentialId: fullHost.credentialId, + sourceUserId: fullHost.userId, + endpointIP: endpointHost.ip, + endpointSSHPort: endpointHost.port, + endpointUsername: endpointHost.username, + endpointPassword: + endpointHost.authType === "password" + ? endpointHost.password + : undefined, + endpointAuthMethod: endpointHost.authType, + endpointSSHKey: + endpointHost.authType === "key" ? endpointHost.key : undefined, + endpointKeyPassword: + endpointHost.authType === "key" + ? endpointHost.keyPassword + : undefined, + endpointKeyType: + endpointHost.authType === "key" ? endpointHost.keyType : undefined, + endpointCredentialId: endpointHost.credentialId, + endpointUserId: endpointHost.userId, + sourcePort: tunnel.sourcePort, + endpointPort: tunnel.endpointPort, + maxRetries: tunnel.maxRetries, + retryInterval: tunnel.retryInterval * 1000, + autoStart: tunnel.autoStart, + isPinned: fullHost.pin, + }; + + await connectTunnel(tunnelConfig); + showToast.success(`Connecting tunnel on port ${tunnel.sourcePort}`); + } else if (action === "disconnect") { + await disconnectTunnel(tunnelName); + showToast.success(`Disconnecting tunnel on port ${tunnel.sourcePort}`); + } else if (action === "cancel") { + await cancelTunnel(tunnelName); + showToast.success(`Cancelling tunnel on port ${tunnel.sourcePort}`); + } + + await fetchTunnelStatuses(false); + } catch (err: any) { + const errorMsg = err?.message || `Failed to ${action} tunnel`; + showToast.error(errorMsg); + } finally { + setLoadingTunnels((prev) => { + const newSet = new Set(prev); + newSet.delete(tunnelName); + return newSet; + }); + } + }; + + const cardWidth = + isLandscape && columnCount > 1 ? `${100 / columnCount - 1}%` : "100%"; + + if (!isVisible) { + return null; + } + + return ( + + {isLoading && !tunnelStatuses ? ( + + + + Loading tunnels... + + + ) : error ? ( + + + + Failed to Load Tunnels + + + {error} + + + + Retry + + + + ) : !currentHostConfig.enableTunnel || + !currentHostConfig.tunnelConnections || + currentHostConfig.tunnelConnections.length === 0 ? ( + + + + No Tunnels Configured + + + This host doesn't have any SSH tunnels configured. + + + Configure tunnels from the desktop app. + + + ) : ( + + } + > + + + SSH Tunnels + + + {currentHostConfig.tunnelConnections.length} tunnel + {currentHostConfig.tunnelConnections.length !== 1 ? "s" : ""}{" "} + configured for {currentHostConfig.name} + + + + 1 ? "row" : "column", + flexWrap: "wrap", + gap: 12, + }} + > + {currentHostConfig.tunnelConnections.map((tunnel, idx) => { + const tunnelName = `${currentHostConfig.name || `${currentHostConfig.id}`}_${tunnel.sourcePort}_${tunnel.endpointHost}_${tunnel.endpointPort}`; + const status = tunnelStatuses[tunnelName] || null; + const isLoadingTunnel = loadingTunnels.has(tunnelName); + + return ( + 1 ? 0 : 12, + }} + > + handleTunnelAction(action, idx)} + /> + + ); + })} + + + )} + + ); +}); + +export default TunnelManager; diff --git a/app/Tabs/Settings/KeyboardCustomization.tsx b/app/tabs/settings/KeyboardCustomization.tsx similarity index 98% rename from app/Tabs/Settings/KeyboardCustomization.tsx rename to app/tabs/settings/KeyboardCustomization.tsx index f9048e7..74381a3 100644 --- a/app/Tabs/Settings/KeyboardCustomization.tsx +++ b/app/tabs/settings/KeyboardCustomization.tsx @@ -6,11 +6,12 @@ import { Switch, Modal, Pressable, + ScrollView, } from "react-native"; import { useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext"; -import { PRESET_DEFINITIONS } from "@/app/Tabs/Sessions/KeyDefinitions"; +import { PRESET_DEFINITIONS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions"; import { PresetType, KeyConfig } from "@/types/keyboard"; import { showToast } from "@/app/utils/toast"; import KeySelector from "./components/KeySelector"; @@ -273,7 +274,7 @@ export default function KeyboardCustomization() { }; const renderPresets = () => ( - + Keyboard Presets @@ -316,7 +317,7 @@ export default function KeyboardCustomization() { )} - + ); const validateTopBarDrag = (newData: UnifiedListItem[]): boolean => { @@ -514,7 +515,7 @@ export default function KeyboardCustomization() { ); const renderSettings = () => ( - + Keyboard Settings @@ -609,7 +610,7 @@ export default function KeyboardCustomization() { Reset Everything to Default - + ); return ( diff --git a/app/Tabs/Settings/Settings.tsx b/app/tabs/settings/Settings.tsx similarity index 89% rename from app/Tabs/Settings/Settings.tsx rename to app/tabs/settings/Settings.tsx index 5402597..355a3d6 100644 --- a/app/Tabs/Settings/Settings.tsx +++ b/app/tabs/settings/Settings.tsx @@ -4,9 +4,12 @@ import { useRouter } from "expo-router"; import { useAppContext } from "@/app/AppContext"; import { useTerminalSessions } from "@/app/contexts/TerminalSessionsContext"; import { clearAuth, clearServerConfig, logoutUser } from "@/app/main-axios"; +import { useOrientation } from "@/app/utils/orientation"; +import { getResponsivePadding } from "@/app/utils/responsive"; export default function Settings() { const router = useRouter(); + const { isLandscape } = useOrientation(); const { setAuthenticated, setShowLoginForm, @@ -17,6 +20,8 @@ export default function Settings() { const { clearAllSessions } = useTerminalSessions(); const insets = useSafeAreaInsets(); + const padding = getResponsivePadding(isLandscape); + const handleLogout = async () => { try { await logoutUser(); @@ -35,7 +40,7 @@ export default function Settings() { return ( - + - router.push("/Tabs/Settings/TerminalCustomization" as any) + 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" > @@ -71,7 +76,7 @@ export default function Settings() { - router.push("/Tabs/Settings/KeyboardCustomization" as any) + 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" > diff --git a/app/Tabs/Settings/TerminalCustomization.tsx b/app/tabs/settings/TerminalCustomization.tsx similarity index 96% rename from app/Tabs/Settings/TerminalCustomization.tsx rename to app/tabs/settings/TerminalCustomization.tsx index 2cbee3e..1170107 100644 --- a/app/Tabs/Settings/TerminalCustomization.tsx +++ b/app/tabs/settings/TerminalCustomization.tsx @@ -90,7 +90,10 @@ export default function TerminalCustomization() { - + Terminal Settings @@ -104,7 +107,8 @@ export default function TerminalCustomization() { Base font size for terminal text. The actual size will be adjusted - based on your screen width. + based on your screen width. This number will override the font size + you configured on a host in the Termix Web UI. {FONT_SIZE_OPTIONS.map((option) => ( @@ -196,6 +200,7 @@ export default function TerminalCustomization() { setShowCustomInput(false); setCustomFontSize(""); }} + supportedOrientations={["portrait", "landscape"]} > setShowResetConfirm(false)} + supportedOrientations={["portrait", "landscape"]} > height; + const orientation: Orientation = isLandscape ? "landscape" : "portrait"; + + return { + width, + height, + isLandscape, + isPortrait: !isLandscape, + orientation, + }; +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/app/utils/responsive.ts b/app/utils/responsive.ts new file mode 100644 index 0000000..8faa476 --- /dev/null +++ b/app/utils/responsive.ts @@ -0,0 +1,43 @@ +export function getColumnCount( + width: number, + isLandscape: boolean, + itemMinWidth: number = 300, +): number { + if (!isLandscape) return 1; + + const columns = Math.floor(width / itemMinWidth); + return Math.max(2, Math.min(columns, 3)); +} + +export function getResponsivePadding( + isLandscape: boolean, + portraitPadding: number = 24, +): number { + return isLandscape ? portraitPadding * 0.67 : portraitPadding; +} + +export function getResponsiveFontSize( + isLandscape: boolean, + baseFontSize: number, +): number { + return isLandscape ? baseFontSize * 0.9 : baseFontSize; +} + +export function getMaxKeyboardHeight( + screenHeight: number, + isLandscape: boolean, +): number { + if (!isLandscape) return screenHeight; + return screenHeight * 0.4; +} + +export function getTabBarHeight(isLandscape: boolean): number { + return isLandscape ? 50 : 60; +} + +export function getButtonSize( + isLandscape: boolean, + portraitSize: number = 44, +): number { + return isLandscape ? portraitSize * 0.82 : portraitSize; +} diff --git a/constants/stats-config.ts b/constants/stats-config.ts new file mode 100644 index 0000000..9b978e2 --- /dev/null +++ b/constants/stats-config.ts @@ -0,0 +1,16 @@ +export type WidgetType = + | 'cpu' + | 'memory' + | 'disk' + +export interface StatsConfig { + enabledWidgets: WidgetType[]; + statusCheckEnabled?: boolean; + metricsEnabled?: boolean; +} + +export const DEFAULT_STATS_CONFIG: StatsConfig = { + enabledWidgets: ['cpu', 'memory', 'disk'], + statusCheckEnabled: true, + metricsEnabled: true, +}; diff --git a/constants/terminal-config.ts b/constants/terminal-config.ts new file mode 100644 index 0000000..6200d9c --- /dev/null +++ b/constants/terminal-config.ts @@ -0,0 +1,9 @@ +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 diff --git a/constants/terminal-themes.ts b/constants/terminal-themes.ts new file mode 100644 index 0000000..14620fc --- /dev/null +++ b/constants/terminal-themes.ts @@ -0,0 +1,710 @@ +export interface TerminalTheme { + name: string; + category: "dark" | "light" | "colorful"; + colors: { + background: string; + foreground: string; + cursor?: string; + cursorAccent?: string; + selectionBackground?: string; + selectionForeground?: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; + }; +} + +export const TERMINAL_THEMES: Record = { + termix: { + name: "Termix Default", + category: "dark", + colors: { + background: "#18181b", + foreground: "#f7f7f7", + cursor: "#f7f7f7", + cursorAccent: "#18181b", + selectionBackground: "#3a3a3d", + black: "#2e3436", + red: "#cc0000", + green: "#4e9a06", + yellow: "#c4a000", + blue: "#3465a4", + magenta: "#75507b", + cyan: "#06989a", + white: "#d3d7cf", + brightBlack: "#555753", + brightRed: "#ef2929", + brightGreen: "#8ae234", + brightYellow: "#fce94f", + brightBlue: "#729fcf", + brightMagenta: "#ad7fa8", + brightCyan: "#34e2e2", + brightWhite: "#eeeeec", + }, + }, + + dracula: { + name: "Dracula", + category: "dark", + colors: { + background: "#282a36", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#282a36", + selectionBackground: "#44475a", + black: "#21222c", + red: "#ff5555", + green: "#50fa7b", + yellow: "#f1fa8c", + blue: "#bd93f9", + magenta: "#ff79c6", + cyan: "#8be9fd", + white: "#f8f8f2", + brightBlack: "#6272a4", + brightRed: "#ff6e6e", + brightGreen: "#69ff94", + brightYellow: "#ffffa5", + brightBlue: "#d6acff", + brightMagenta: "#ff92df", + brightCyan: "#a4ffff", + brightWhite: "#ffffff", + }, + }, + + monokai: { + name: "Monokai", + category: "dark", + colors: { + background: "#272822", + foreground: "#f8f8f2", + cursor: "#f8f8f0", + cursorAccent: "#272822", + selectionBackground: "#49483e", + black: "#272822", + red: "#f92672", + green: "#a6e22e", + yellow: "#f4bf75", + blue: "#66d9ef", + magenta: "#ae81ff", + cyan: "#a1efe4", + white: "#f8f8f2", + brightBlack: "#75715e", + brightRed: "#f92672", + brightGreen: "#a6e22e", + brightYellow: "#f4bf75", + brightBlue: "#66d9ef", + brightMagenta: "#ae81ff", + brightCyan: "#a1efe4", + brightWhite: "#f9f8f5", + }, + }, + + nord: { + name: "Nord", + category: "dark", + colors: { + background: "#2e3440", + foreground: "#d8dee9", + cursor: "#d8dee9", + cursorAccent: "#2e3440", + selectionBackground: "#434c5e", + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#81a1c1", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#e5e9f0", + brightBlack: "#4c566a", + brightRed: "#bf616a", + brightGreen: "#a3be8c", + brightYellow: "#ebcb8b", + brightBlue: "#81a1c1", + brightMagenta: "#b48ead", + brightCyan: "#8fbcbb", + brightWhite: "#eceff4", + }, + }, + + gruvboxDark: { + name: "Gruvbox Dark", + category: "dark", + colors: { + background: "#282828", + foreground: "#ebdbb2", + cursor: "#ebdbb2", + cursorAccent: "#282828", + selectionBackground: "#504945", + black: "#282828", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + magenta: "#b16286", + cyan: "#689d6a", + white: "#a89984", + brightBlack: "#928374", + brightRed: "#fb4934", + brightGreen: "#b8bb26", + brightYellow: "#fabd2f", + brightBlue: "#83a598", + brightMagenta: "#d3869b", + brightCyan: "#8ec07c", + brightWhite: "#ebdbb2", + }, + }, + + gruvboxLight: { + name: "Gruvbox Light", + category: "light", + colors: { + background: "#fbf1c7", + foreground: "#3c3836", + cursor: "#3c3836", + cursorAccent: "#fbf1c7", + selectionBackground: "#d5c4a1", + black: "#fbf1c7", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + magenta: "#b16286", + cyan: "#689d6a", + white: "#7c6f64", + brightBlack: "#928374", + brightRed: "#9d0006", + brightGreen: "#79740e", + brightYellow: "#b57614", + brightBlue: "#076678", + brightMagenta: "#8f3f71", + brightCyan: "#427b58", + brightWhite: "#3c3836", + }, + }, + + solarizedDark: { + name: "Solarized Dark", + category: "dark", + colors: { + background: "#002b36", + foreground: "#839496", + cursor: "#839496", + cursorAccent: "#002b36", + selectionBackground: "#073642", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#002b36", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + }, + + solarizedLight: { + name: "Solarized Light", + category: "light", + colors: { + background: "#fdf6e3", + foreground: "#657b83", + cursor: "#657b83", + cursorAccent: "#fdf6e3", + selectionBackground: "#eee8d5", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#002b36", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + }, + + oneDark: { + name: "One Dark", + category: "dark", + colors: { + background: "#282c34", + foreground: "#abb2bf", + cursor: "#528bff", + cursorAccent: "#282c34", + selectionBackground: "#3e4451", + black: "#282c34", + red: "#e06c75", + green: "#98c379", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#abb2bf", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", + }, + }, + + tokyoNight: { + name: "Tokyo Night", + category: "dark", + colors: { + background: "#1a1b26", + foreground: "#a9b1d6", + cursor: "#a9b1d6", + cursorAccent: "#1a1b26", + selectionBackground: "#283457", + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", + }, + }, + + ayuDark: { + name: "Ayu Dark", + category: "dark", + colors: { + background: "#0a0e14", + foreground: "#b3b1ad", + cursor: "#e6b450", + cursorAccent: "#0a0e14", + selectionBackground: "#253340", + black: "#01060e", + red: "#ea6c73", + green: "#91b362", + yellow: "#f9af4f", + blue: "#53bdfa", + magenta: "#fae994", + cyan: "#90e1c6", + white: "#c7c7c7", + brightBlack: "#686868", + brightRed: "#f07178", + brightGreen: "#c2d94c", + brightYellow: "#ffb454", + brightBlue: "#59c2ff", + brightMagenta: "#ffee99", + brightCyan: "#95e6cb", + brightWhite: "#ffffff", + }, + }, + + ayuLight: { + name: "Ayu Light", + category: "light", + colors: { + background: "#fafafa", + foreground: "#5c6166", + cursor: "#ff9940", + cursorAccent: "#fafafa", + selectionBackground: "#d1e4f4", + black: "#000000", + red: "#f51818", + green: "#86b300", + yellow: "#f2ae49", + blue: "#399ee6", + magenta: "#a37acc", + cyan: "#4cbf99", + white: "#c7c7c7", + brightBlack: "#686868", + brightRed: "#ff3333", + brightGreen: "#b8e532", + brightYellow: "#ffc849", + brightBlue: "#59c2ff", + brightMagenta: "#bf7ce0", + brightCyan: "#5cf7a0", + brightWhite: "#ffffff", + }, + }, + + materialTheme: { + name: "Material Theme", + category: "dark", + colors: { + background: "#263238", + foreground: "#eeffff", + cursor: "#ffcc00", + cursorAccent: "#263238", + selectionBackground: "#546e7a", + black: "#000000", + red: "#e53935", + green: "#91b859", + yellow: "#ffb62c", + blue: "#6182b8", + magenta: "#7c4dff", + cyan: "#39adb5", + white: "#ffffff", + brightBlack: "#546e7a", + brightRed: "#ff5370", + brightGreen: "#c3e88d", + brightYellow: "#ffcb6b", + brightBlue: "#82aaff", + brightMagenta: "#c792ea", + brightCyan: "#89ddff", + brightWhite: "#ffffff", + }, + }, + + palenight: { + name: "Palenight", + category: "dark", + colors: { + background: "#292d3e", + foreground: "#a6accd", + cursor: "#ffcc00", + cursorAccent: "#292d3e", + selectionBackground: "#676e95", + black: "#292d3e", + red: "#f07178", + green: "#c3e88d", + yellow: "#ffcb6b", + blue: "#82aaff", + magenta: "#c792ea", + cyan: "#89ddff", + white: "#d0d0d0", + brightBlack: "#434758", + brightRed: "#ff8b92", + brightGreen: "#ddffa7", + brightYellow: "#ffe585", + brightBlue: "#9cc4ff", + brightMagenta: "#e1acff", + brightCyan: "#a3f7ff", + brightWhite: "#ffffff", + }, + }, + + oceanicNext: { + name: "Oceanic Next", + category: "dark", + colors: { + background: "#1b2b34", + foreground: "#cdd3de", + cursor: "#c0c5ce", + cursorAccent: "#1b2b34", + selectionBackground: "#343d46", + black: "#343d46", + red: "#ec5f67", + green: "#99c794", + yellow: "#fac863", + blue: "#6699cc", + magenta: "#c594c5", + cyan: "#5fb3b3", + white: "#cdd3de", + brightBlack: "#65737e", + brightRed: "#ec5f67", + brightGreen: "#99c794", + brightYellow: "#fac863", + brightBlue: "#6699cc", + brightMagenta: "#c594c5", + brightCyan: "#5fb3b3", + brightWhite: "#d8dee9", + }, + }, + + nightOwl: { + name: "Night Owl", + category: "dark", + colors: { + background: "#011627", + foreground: "#d6deeb", + cursor: "#80a4c2", + cursorAccent: "#011627", + selectionBackground: "#1d3b53", + black: "#011627", + red: "#ef5350", + green: "#22da6e", + yellow: "#c5e478", + blue: "#82aaff", + magenta: "#c792ea", + cyan: "#21c7a8", + white: "#ffffff", + brightBlack: "#575656", + brightRed: "#ef5350", + brightGreen: "#22da6e", + brightYellow: "#ffeb95", + brightBlue: "#82aaff", + brightMagenta: "#c792ea", + brightCyan: "#7fdbca", + brightWhite: "#ffffff", + }, + }, + + synthwave84: { + name: "Synthwave '84", + category: "colorful", + colors: { + background: "#241b2f", + foreground: "#f92aad", + cursor: "#f92aad", + cursorAccent: "#241b2f", + selectionBackground: "#495495", + black: "#000000", + red: "#f6188f", + green: "#1eff8e", + yellow: "#ffe261", + blue: "#03edf9", + magenta: "#f10596", + cyan: "#03edf9", + white: "#ffffff", + brightBlack: "#5a5a5a", + brightRed: "#ff1a8e", + brightGreen: "#1eff8e", + brightYellow: "#ffff00", + brightBlue: "#00d8ff", + brightMagenta: "#ff00d4", + brightCyan: "#00ffff", + brightWhite: "#ffffff", + }, + }, + + cobalt2: { + name: "Cobalt2", + category: "dark", + colors: { + background: "#193549", + foreground: "#ffffff", + cursor: "#f0cc09", + cursorAccent: "#193549", + selectionBackground: "#0050a4", + black: "#000000", + red: "#ff0000", + green: "#38de21", + yellow: "#ffe50a", + blue: "#1460d2", + magenta: "#ff005d", + cyan: "#00bbbb", + white: "#bbbbbb", + brightBlack: "#555555", + brightRed: "#f40e17", + brightGreen: "#3bd01d", + brightYellow: "#edc809", + brightBlue: "#5555ff", + brightMagenta: "#ff55ff", + brightCyan: "#6ae3fa", + brightWhite: "#ffffff", + }, + }, + + snazzy: { + name: "Snazzy", + category: "dark", + colors: { + background: "#282a36", + foreground: "#eff0eb", + cursor: "#97979b", + cursorAccent: "#282a36", + selectionBackground: "#97979b", + black: "#282a36", + red: "#ff5c57", + green: "#5af78e", + yellow: "#f3f99d", + blue: "#57c7ff", + magenta: "#ff6ac1", + cyan: "#9aedfe", + white: "#f1f1f0", + brightBlack: "#686868", + brightRed: "#ff5c57", + brightGreen: "#5af78e", + brightYellow: "#f3f99d", + brightBlue: "#57c7ff", + brightMagenta: "#ff6ac1", + brightCyan: "#9aedfe", + brightWhite: "#eff0eb", + }, + }, + + atomOneDark: { + name: "Atom One Dark", + category: "dark", + colors: { + background: "#1e2127", + foreground: "#abb2bf", + cursor: "#528bff", + cursorAccent: "#1e2127", + selectionBackground: "#3e4451", + black: "#000000", + red: "#e06c75", + green: "#98c379", + yellow: "#d19a66", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#abb2bf", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#d19a66", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", + }, + }, + + catppuccinMocha: { + name: "Catppuccin Mocha", + category: "dark", + colors: { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + cursorAccent: "#1e1e2e", + selectionBackground: "#585b70", + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f38ba8", + brightGreen: "#a6e3a1", + brightYellow: "#f9e2af", + brightBlue: "#89b4fa", + brightMagenta: "#f5c2e7", + brightCyan: "#94e2d5", + brightWhite: "#a6adc8", + }, + }, +}; + +export const TERMINAL_FONTS = [ + { + value: "Caskaydia Cove Nerd Font Mono", + label: "Caskaydia Cove Nerd Font Mono", + fallback: + '"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "JetBrains Mono", + label: "JetBrains Mono", + fallback: + '"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "Fira Code", + label: "Fira Code", + fallback: '"Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "Cascadia Code", + label: "Cascadia Code", + fallback: + '"Cascadia Code", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "Source Code Pro", + label: "Source Code Pro", + fallback: + '"Source Code Pro", "SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "SF Mono", + label: "SF Mono", + fallback: '"SF Mono", Consolas, "Liberation Mono", monospace', + }, + { + value: "Consolas", + label: "Consolas", + fallback: 'Consolas, "Liberation Mono", monospace', + }, + { + value: "Monaco", + label: "Monaco", + fallback: 'Monaco, "Liberation Mono", monospace', + }, +]; + +export const CURSOR_STYLES = [ + { value: "block", label: "Block" }, + { value: "underline", label: "Underline" }, + { value: "bar", label: "Bar" }, +] as const; + +export const BELL_STYLES = [ + { value: "none", label: "None" }, + { value: "sound", label: "Sound" }, + { value: "visual", label: "Visual" }, + { value: "both", label: "Both" }, +] as const; + +export const FAST_SCROLL_MODIFIERS = [ + { value: "alt", label: "Alt" }, + { value: "ctrl", label: "Ctrl" }, + { value: "shift", label: "Shift" }, +] as const; + +export const DEFAULT_TERMINAL_CONFIG = { + cursorBlink: true, + cursorStyle: "bar" as const, + fontSize: 14, + fontFamily: "Caskaydia Cove Nerd Font Mono", + letterSpacing: 0, + lineHeight: 1.2, + theme: "termix", + + scrollback: 10000, + bellStyle: "none" as const, + rightClickSelectsWord: false, + fastScrollModifier: "alt" as const, + fastScrollSensitivity: 5, + minimumContrastRatio: 1, + + backspaceMode: "normal" as const, + agentForwarding: false, + environmentVariables: [] as Array<{ key: string; value: string }>, + startupSnippetId: null as number | null, + autoMosh: false, + moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8", +}; + +export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG; diff --git a/package-lock.json b/package-lock.json index b4f9583..b1f208b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix-mobile", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix-mobile", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", @@ -20,6 +20,7 @@ "eslint-plugin-prettier": "^5.5.4", "expo": "54.0.13", "expo-build-properties": "^1.0.9", + "expo-clipboard": "^8.0.8", "expo-constants": "~18.0.8", "expo-dev-client": "~6.0.15", "expo-font": "~14.0.8", @@ -112,6 +113,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1493,6 +1495,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1746,6 +1749,7 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3969,6 +3973,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.17.tgz", "integrity": "sha512-uEcYWi1NV+2Qe1oELfp9b5hTYekqWATv2cuwcOAg5EvsIsUPtzFrKIasgUXLBRGb9P7yR5ifoJ+ug4u6jdqSTQ==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.12.4", "escape-string-regexp": "^4.0.0", @@ -4162,6 +4167,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.3.tgz", "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -4172,6 +4178,7 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4243,6 +4250,7 @@ "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/types": "8.43.0", @@ -4805,6 +4813,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5535,6 +5544,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.2", "caniuse-lite": "^1.0.30001741", @@ -6983,6 +6993,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7076,6 +7087,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7195,6 +7207,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7456,6 +7469,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.13.tgz", "integrity": "sha512-F1puKXzw8ESnsbvaKdXtcIiyYLQ2kUHqP8LuhgtJS1wm6w55VhtOPg8yl/0i8kPbTA0YfD+KYdXjSfhPXgUPxw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.11", @@ -7565,11 +7579,23 @@ "node": ">=10" } }, + "node_modules/expo-clipboard": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz", + "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.9", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.9.tgz", "integrity": "sha512-sqoXHAOGDcr+M9NlXzj1tGoZyd3zxYDy215W6E0Z0n8fgBaqce9FAYQE2bu5X4G629AYig5go7U6sQz7Pjcm8A==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~12.0.9", "@expo/env": "~2.0.7" @@ -7644,6 +7670,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz", "integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -7700,6 +7727,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz", "integrity": "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" @@ -11758,6 +11786,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -11902,6 +11931,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12209,6 +12239,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12228,6 +12259,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -12280,6 +12312,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.4", @@ -12634,6 +12667,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -12659,6 +12693,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.2.tgz", "integrity": "sha512-qzmQiFrvjm62pRBcj97QI9Xckc3EjgHQoY1F2yjktd0kpjhoyePeuTEXjYRCAVIy7IV/1cfeSup34+zFThFoHQ==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -12687,6 +12722,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.1.tgz", "integrity": "sha512-/wJE58HLEAkATzhhX1xSr+fostLsK8Q97EfpfMDKo8jlOc1QKESSX/FQrhk7HhQH/2uSaox4Y86sNaI02kteiA==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -12697,6 +12733,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -12712,6 +12749,7 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.14.0.tgz", "integrity": "sha512-B3gYc7WztcOT4N54AtUutbe0Nuqqh/nkresY0fAXzUHYLsWuIu/yGiCCD3DKfAs6GLv5LFtWTu7N333Q+e3bkg==", "license": "MIT", + "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -12727,6 +12765,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.1.tgz", "integrity": "sha512-BeNsgwwe4AXUFPAoFU+DKjJ+CVQa3h54zYX77p7GVZrXiiNo3vl03WYDYVEy5R2J2HOPInXtQZB5gmj3vuzrKg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -12759,6 +12798,7 @@ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==", "license": "MIT", + "peer": true, "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" @@ -12860,6 +12900,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14192,6 +14233,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -14425,6 +14467,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14629,6 +14672,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15426,6 +15470,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 440495f..fe10eb2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix-mobile", "main": "expo-router/entry", - "version": "1.1.0", + "version": "1.2.0", "scripts": { "start": "expo start", "android": "expo run:android", @@ -25,6 +25,7 @@ "eslint-plugin-prettier": "^5.5.4", "expo": "54.0.13", "expo-build-properties": "^1.0.9", + "expo-clipboard": "^8.0.8", "expo-constants": "~18.0.8", "expo-dev-client": "~6.0.15", "expo-font": "~14.0.8", diff --git a/plugins/withIOSNetworkSecurity.js b/plugins/withIOSNetworkSecurity.js index c524190..7509b2b 100644 --- a/plugins/withIOSNetworkSecurity.js +++ b/plugins/withIOSNetworkSecurity.js @@ -1,23 +1,45 @@ -const { withInfoPlist } = require("@expo/config-plugins"); +const { withInfoPlist, withDangerousMod, IOSConfig } = require("@expo/config-plugins"); +const fs = require("fs"); const withIOSNetworkSecurity = (config) => { - return withInfoPlist(config, (config) => { - const existingPlist = config.modResults; + config = withInfoPlist(config, (config) => { + delete config.modResults.NSAppTransportSecurity; - existingPlist.NSAppTransportSecurity = { + config.modResults.NSAppTransportSecurity = { NSAllowsArbitraryLoads: true, - NSAllowsLocalNetworking: true, - NSAllowsArbitraryLoadsInWebContent: true, - NSAllowsArbitraryLoadsForMedia: true, }; - existingPlist.NSLocalNetworkUsageDescription = - "Termix needs to connect to servers on your local network for SSH and other services."; + config.modResults.NSLocalNetworkUsageDescription = + "Termix needs to connect to servers to load hosts and initiate SSH connections"; - existingPlist.NSBonjourServices = ["_ssh._tcp", "_sftp-ssh._tcp"]; + config.modResults.NSBonjourServices = ["_ssh._tcp", "_sftp-ssh._tcp"]; return config; }); + + config = withDangerousMod(config, [ + "ios", + async (config) => { + const { platformProjectRoot } = config.modRequest; + + try { + let infoPlist = IOSConfig.InfoPlist.read(platformProjectRoot); + + delete infoPlist.NSAppTransportSecurity; + + infoPlist.NSAppTransportSecurity = { + NSAllowsArbitraryLoads: true, + }; + + IOSConfig.InfoPlist.write(platformProjectRoot, infoPlist); + } catch (e) { + } + + return config; + }, + ]); + + return config; }; -module.exports = withIOSNetworkSecurity; \ No newline at end of file +module.exports = withIOSNetworkSecurity; diff --git a/plugins/withNetworkSecurityConfig.js b/plugins/withNetworkSecurityConfig.js index 53bcd90..a489882 100644 --- a/plugins/withNetworkSecurityConfig.js +++ b/plugins/withNetworkSecurityConfig.js @@ -23,18 +23,13 @@ const withNetworkSecurityConfig = (config) => { const networkSecurityConfig = ` - - - - - localhost 127.0.0.1 diff --git a/tailwind.config.js b/tailwind.config.js index 6debdd9..1997085 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -27,6 +27,11 @@ module.exports = { 'dark-border-panel': '#222224', 'dark-bg-panel-hover': '#232327', }, + borderRadius: { + 'button': '6px', + 'card': '12px', + 'small': '4px', + }, }, }, plugins: [], diff --git a/types/index.ts b/types/index.ts index 95283bf..7c1c417 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,59 +1,96 @@ // ============================================================================ -// CENTRAL TYPE DEFINITIONS +// SSH HOST TYPES // ============================================================================ -// This file contains all shared interfaces and types used across the application -// to avoid duplication and ensure consistency. -// SSH2 types not needed for React Native +export interface JumpHost { + hostId: number; +} -// ============================================================================ -// SSH HOST TYPES -// ============================================================================ +export interface QuickAction { + name: string; + snippetId: number; +} export interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: "password" | "key" | "credential"; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - credentialId?: number; - userId?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: TunnelConnection[]; - createdAt: string; - updatedAt: string; + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: "password" | "key" | "credential" | "none"; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + forceKeyboardInteractive?: boolean; + + autostartPassword?: string; + autostartKey?: string; + autostartKeyPassword?: string; + + credentialId?: number; + overrideCredentialUsername?: boolean; + userId?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + defaultPath: string; + tunnelConnections: TunnelConnection[]; + jumpHosts?: JumpHost[]; + quickActions?: QuickAction[]; + statsConfig?: string; + terminalConfig?: TerminalConfig; + createdAt: string; + updatedAt: string; +} + +export interface JumpHostData { + hostId: number; +} + +export interface QuickActionData { + name: string; + snippetId: number; } export interface SSHHostData { - name?: string; - ip: string; - port: number; - username: string; - folder?: string; - tags?: string[]; - pin?: boolean; - authType: "password" | "key" | "credential"; - password?: string; - key?: File | null; - keyPassword?: string; - keyType?: string; - credentialId?: number | null; - enableTerminal?: boolean; - enableTunnel?: boolean; - enableFileManager?: boolean; - defaultPath?: string; - tunnelConnections?: any[]; + name?: string; + ip: string; + port: number; + username: string; + folder?: string; + tags?: string[]; + pin?: boolean; + authType: "password" | "key" | "credential" | "none"; + password?: string; + key?: File | null; + keyPassword?: string; + keyType?: string; + credentialId?: number | null; + overrideCredentialUsername?: boolean; + enableTerminal?: boolean; + enableTunnel?: boolean; + enableFileManager?: boolean; + defaultPath?: string; + forceKeyboardInteractive?: boolean; + tunnelConnections?: TunnelConnection[]; + jumpHosts?: JumpHostData[]; + quickActions?: QuickActionData[]; + statsConfig?: string | Record; + terminalConfig?: TerminalConfig; +} + +export interface SSHFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; } // ============================================================================ @@ -61,34 +98,58 @@ export interface SSHHostData { // ============================================================================ export interface Credential { - id: number; - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: "password" | "key"; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - usageCount: number; - lastUsed?: string; - createdAt: string; - updatedAt: string; + id: number; + name: string; + description?: string; + folder?: string; + tags: string[]; + authType: "password" | "key"; + username: string; + password?: string; + key?: string; + publicKey?: string; + keyPassword?: string; + keyType?: string; + usageCount: number; + lastUsed?: string; + createdAt: string; + updatedAt: string; +} + +export interface CredentialBackend { + id: number; + userId: string; + name: string; + description: string | null; + folder: string | null; + tags: string; + authType: "password" | "key"; + username: string; + password: string | null; + key: string; + private_key?: string; + public_key?: string; + key_password: string | null; + keyType?: string; + detectedKeyType: string; + usageCount: number; + lastUsed: string | null; + createdAt: string; + updatedAt: string; } export interface CredentialData { - name: string; - description?: string; - folder?: string; - tags: string[]; - authType: "password" | "key"; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; + name: string; + description?: string; + folder?: string; + tags: string[]; + authType: "password" | "key"; + username: string; + password?: string; + key?: string; + publicKey?: string; + keyPassword?: string; + keyType?: string; } // ============================================================================ @@ -96,55 +157,62 @@ export interface CredentialData { // ============================================================================ export interface TunnelConnection { - sourcePort: number; - endpointPort: number; - endpointHost: string; - maxRetries: number; - retryInterval: number; - autoStart: boolean; + sourcePort: number; + endpointPort: number; + endpointHost: string; + + endpointPassword?: string; + endpointKey?: string; + endpointKeyPassword?: string; + endpointAuthType?: string; + endpointKeyType?: string; + + maxRetries: number; + retryInterval: number; + autoStart: boolean; } export interface TunnelConfig { - name: string; - hostName: string; - sourceIP: string; - sourceSSHPort: number; - sourceUsername: string; - sourcePassword?: string; - sourceAuthMethod: string; - sourceSSHKey?: string; - sourceKeyPassword?: string; - sourceKeyType?: string; - sourceCredentialId?: number; - sourceUserId?: string; - endpointIP: string; - endpointSSHPort: number; - endpointUsername: string; - endpointPassword?: string; - endpointAuthMethod: string; - endpointSSHKey?: string; - endpointKeyPassword?: string; - endpointKeyType?: string; - endpointCredentialId?: number; - endpointUserId?: string; - sourcePort: number; - endpointPort: number; - maxRetries: number; - retryInterval: number; - autoStart: boolean; - isPinned: boolean; + name: string; + hostName: string; + sourceIP: string; + sourceSSHPort: number; + sourceUsername: string; + sourcePassword?: string; + sourceAuthMethod: string; + sourceSSHKey?: string; + sourceKeyPassword?: string; + sourceKeyType?: string; + sourceCredentialId?: number; + sourceUserId?: string; + endpointIP: string; + endpointSSHPort: number; + endpointUsername: string; + endpointPassword?: string; + endpointAuthMethod: string; + endpointSSHKey?: string; + endpointKeyPassword?: string; + endpointKeyType?: string; + endpointCredentialId?: number; + endpointUserId?: string; + sourcePort: number; + endpointPort: number; + maxRetries: number; + retryInterval: number; + autoStart: boolean; + isPinned: boolean; } export interface TunnelStatus { - connected: boolean; - status: ConnectionState; - retryCount?: number; - maxRetries?: number; - nextRetryIn?: number; - reason?: string; - errorType?: ErrorType; - manualDisconnect?: boolean; - retryExhausted?: boolean; + connected: boolean; + status: ConnectionState; + retryCount?: number; + maxRetries?: number; + nextRetryIn?: number; + reason?: string; + errorType?: ErrorType; + manualDisconnect?: boolean; + retryExhausted?: boolean; } // ============================================================================ @@ -152,50 +220,57 @@ export interface TunnelStatus { // ============================================================================ export interface Tab { - id: string | number; - title: string; - fileName: string; - content: string; - isSSH?: boolean; - sshSessionId?: string; - filePath?: string; - loading?: boolean; - dirty?: boolean; + id: string | number; + title: string; + fileName: string; + content: string; + isSSH?: boolean; + sshSessionId?: string; + filePath?: string; + loading?: boolean; + dirty?: boolean; } export interface FileManagerFile { - name: string; - path: string; - type?: "file" | "directory"; - isSSH?: boolean; - sshSessionId?: string; + name: string; + path: string; + type?: "file" | "directory"; + isSSH?: boolean; + sshSessionId?: string; } export interface FileManagerShortcut { - name: string; - path: string; + name: string; + path: string; } export interface FileItem { - name: string; - path: string; - isPinned?: boolean; - type: "file" | "directory"; - sshSessionId?: string; + name: string; + path: string; + isPinned?: boolean; + type: "file" | "directory" | "link"; + sshSessionId?: string; + size?: number; + modified?: string; + permissions?: string; + owner?: string; + group?: string; + linkTarget?: string; + executable?: boolean; } export interface ShortcutItem { - name: string; - path: string; + name: string; + path: string; } export interface SSHConnection { - id: number; - name: string; - ip: string; - port: number; - username: string; - isPinned?: boolean; + id: number; + name: string; + ip: string; + port: number; + username: string; + isPinned?: boolean; } // ============================================================================ @@ -203,11 +278,11 @@ export interface SSHConnection { // ============================================================================ export interface HostInfo { - id: number; - name?: string; - ip: string; - port: number; - createdAt: string; + id: number; + name?: string; + ip: string; + port: number; + createdAt: string; } // ============================================================================ @@ -215,14 +290,43 @@ export interface HostInfo { // ============================================================================ export interface TermixAlert { - id: string; - title: string; - message: string; - expiresAt: string; - priority?: "low" | "medium" | "high" | "critical"; - type?: "info" | "warning" | "error" | "success"; - actionUrl?: string; - actionText?: string; + id: string; + title: string; + message: string; + expiresAt: string; + priority?: "low" | "medium" | "high" | "critical"; + type?: "info" | "warning" | "error" | "success"; + actionUrl?: string; + actionText?: string; +} + +// ============================================================================ +// TERMINAL CONFIGURATION TYPES +// ============================================================================ + +export interface TerminalConfig { + cursorBlink: boolean; + cursorStyle: "block" | "underline" | "bar"; + fontSize: number; + fontFamily: string; + letterSpacing: number; + lineHeight: number; + theme: string; + + scrollback: number; + bellStyle: "none" | "sound" | "visual" | "both"; + rightClickSelectsWord: boolean; + fastScrollModifier: "alt" | "ctrl" | "shift"; + fastScrollSensitivity: number; + minimumContrastRatio: number; + + backspaceMode: "normal" | "control-h"; + agentForwarding: boolean; + environmentVariables: Array<{ key: string; value: string }>; + startupSnippetId: number | null; + autoMosh: boolean; + moshCommand: string; + sudoPasswordAutoFill: boolean; } // ============================================================================ @@ -230,18 +334,54 @@ export interface TermixAlert { // ============================================================================ export interface TabContextTab { + id: number; + type: + | "home" + | "terminal" + | "ssh_manager" + | "server" + | "admin" + | "file_manager" + | "user_profile"; + title: string; + hostConfig?: SSHHost; + terminalRef?: any; + initialTab?: string; +} + +export interface TunnelSessionProps { + hostConfig: { id: number; - type: - | "home" - | "terminal" - | "ssh_manager" - | "server" - | "admin" - | "file_manager" - | "user_profile"; - title: string; - hostConfig?: any; - terminalRef?: React.RefObject; + name: string; + enableTunnel: boolean; + tunnelConnections: TunnelConnection[]; + }; + isVisible: boolean; + title?: string; + onClose?: () => void; +} + +export interface TunnelCardProps { + tunnel: TunnelConnection; + tunnelName: string; + status: TunnelStatus | null; + isLoading: boolean; + onAction: (action: "connect" | "disconnect" | "cancel") => Promise; +} + +export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; + +export interface SplitConfiguration { + layout: SplitLayout; + positions: Map; +} + +export interface SplitLayoutOption { + id: SplitLayout; + name: string; + description: string; + cellCount: number; + icon: string; // lucide icon name } // ============================================================================ @@ -249,32 +389,32 @@ export interface TabContextTab { // ============================================================================ export const CONNECTION_STATES = { - DISCONNECTED: "disconnected", - CONNECTING: "connecting", - CONNECTED: "connected", - VERIFYING: "verifying", - FAILED: "failed", - UNSTABLE: "unstable", - RETRYING: "retrying", - WAITING: "waiting", - DISCONNECTING: "disconnecting", + DISCONNECTED: "disconnected", + CONNECTING: "connecting", + CONNECTED: "connected", + VERIFYING: "verifying", + FAILED: "failed", + UNSTABLE: "unstable", + RETRYING: "retrying", + WAITING: "waiting", + DISCONNECTING: "disconnecting", } as const; export type ConnectionState = - (typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES]; + (typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES]; export type ErrorType = - | "CONNECTION_FAILED" - | "AUTHENTICATION_FAILED" - | "TIMEOUT" - | "NETWORK_ERROR" - | "UNKNOWN"; + | "CONNECTION_FAILED" + | "AUTHENTICATION_FAILED" + | "TIMEOUT" + | "NETWORK_ERROR" + | "UNKNOWN"; // ============================================================================ // AUTHENTICATION TYPES // ============================================================================ -export type AuthType = "password" | "key" | "credential"; +export type AuthType = "password" | "key" | "credential" | "none"; export type KeyType = "rsa" | "ecdsa" | "ed25519"; @@ -282,11 +422,11 @@ export type KeyType = "rsa" | "ecdsa" | "ed25519"; // API RESPONSE TYPES // ============================================================================ -export interface ApiResponse { - data?: T; - error?: string; - message?: string; - status?: number; +export interface ApiResponse { + data?: T; + error?: string; + message?: string; + status?: number; } // ============================================================================ @@ -294,122 +434,140 @@ export interface ApiResponse { // ============================================================================ export interface CredentialsManagerProps { - onEditCredential?: (credential: Credential) => void; + onEditCredential?: (credential: Credential) => void; } export interface CredentialEditorProps { - editingCredential?: Credential | null; - onFormSubmit?: () => void; + editingCredential?: Credential | null; + onFormSubmit?: () => void; } export interface CredentialViewerProps { - credential: Credential; - onClose: () => void; - onEdit: () => void; + credential: Credential; + onClose: () => void; + onEdit: () => void; } export interface CredentialSelectorProps { - value?: number | null; - onValueChange: (value: number | null) => void; + value?: number | null; + onValueChange: (value: number | null) => void; } export interface HostManagerProps { - onSelectView?: (view: string) => void; - isTopbarOpen?: boolean; + onSelectView?: (view: string) => void; + isTopbarOpen?: boolean; + initialTab?: string; + hostConfig?: SSHHost; + rightSidebarOpen?: boolean; + rightSidebarWidth?: number; } export interface SSHManagerHostEditorProps { - editingHost?: SSHHost | null; - onFormSubmit?: () => void; + editingHost?: SSHHost | null; + onFormSubmit?: () => void; } export interface SSHManagerHostViewerProps { - onEditHost?: (host: SSHHost) => void; + onEditHost?: (host: SSHHost) => void; } export interface HostProps { - host: SSHHost; - onHostConnect?: () => void; + host: SSHHost; + onHostConnect?: () => void; } export interface SSHTunnelProps { - filterHostKey?: string; + filterHostKey?: string; } export interface SSHTunnelViewerProps { - hosts?: SSHHost[]; - tunnelStatuses?: Record; - tunnelActions?: Record< - string, - ( - action: "connect" | "disconnect" | "cancel", - host: SSHHost, - tunnelIndex: number, - ) => Promise - >; - onTunnelAction?: ( - action: "connect" | "disconnect" | "cancel", - host: SSHHost, - tunnelIndex: number, - ) => Promise; + hosts?: SSHHost[]; + tunnelStatuses?: Record; + tunnelActions?: Record< + string, + ( + action: "connect" | "disconnect" | "cancel", + host: SSHHost, + tunnelIndex: number, + ) => Promise + >; + onTunnelAction?: ( + action: "connect" | "disconnect" | "cancel", + host: SSHHost, + tunnelIndex: number, + ) => Promise; } export interface FileManagerProps { - onSelectView?: (view: string) => void; - embedded?: boolean; - initialHost?: SSHHost | null; -} - -export interface FileManagerLeftSidebarProps { - onSelectView?: (view: string) => void; - onOpenFile: (file: any) => void; - tabs: Tab[]; - host: SSHHost; - onOperationComplete?: () => void; - onError?: (error: string) => void; - onSuccess?: (message: string) => void; - onPathChange?: (path: string) => void; - onDeleteItem?: (item: any) => void; -} - -export interface FileManagerOperationsProps { - currentPath: string; - sshSessionId: string | null; - onOperationComplete?: () => void; - onError?: (error: string) => void; - onSuccess?: (message: string) => void; + onSelectView?: (view: string) => void; + embedded?: boolean; + initialHost?: SSHHost | null; } export interface AlertCardProps { - alert: TermixAlert; - onDismiss: (alertId: string) => void; + alert: TermixAlert; + onDismiss: (alertId: string) => void; } export interface AlertManagerProps { - alerts: TermixAlert[]; - onDismiss: (alertId: string) => void; - loggedIn: boolean; + alerts: TermixAlert[]; + onDismiss: (alertId: string) => void; + loggedIn: boolean; } export interface SSHTunnelObjectProps { - host: SSHHost; - tunnelStatuses: Record; - tunnelActions: Record; - onTunnelAction: ( - action: "connect" | "disconnect" | "cancel", - host: SSHHost, - tunnelIndex: number, - ) => Promise; - compact?: boolean; - bare?: boolean; + host: SSHHost; + tunnelStatuses: Record; + tunnelActions: Record; + onTunnelAction: ( + action: "connect" | "disconnect" | "cancel", + host: SSHHost, + tunnelIndex: number, + ) => Promise; + compact?: boolean; + bare?: boolean; } export interface FolderStats { - totalHosts: number; - hostsByType: Array<{ - type: string; - count: number; - }>; + totalHosts: number; + hostsByType: Array<{ + type: string; + count: number; + }>; +} + +// ============================================================================ +// SNIPPETS TYPES +// ============================================================================ + +export interface Snippet { + id: number; + userId: string; + name: string; + content: string; + description?: string; + folder?: string; + order?: number; + createdAt: string; + updatedAt: string; +} + +export interface SnippetData { + name: string; + content: string; + description?: string; + folder?: string; + order?: number; +} + +export interface SnippetFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; } // ============================================================================ @@ -417,16 +575,16 @@ export interface FolderStats { // ============================================================================ export interface HostConfig { - host: SSHHost; - tunnels: TunnelConfig[]; + host: SSHHost; + tunnels: TunnelConfig[]; } export interface VerificationData { - conn: any; - timeout: NodeJS.Timeout; - startTime: number; - attempts: number; - maxAttempts: number; + conn: any; // Client type from ssh2, using 'any' for React Native compatibility + timeout: NodeJS.Timeout; + startTime: number; + attempts: number; + maxAttempts: number; } // ============================================================================ @@ -437,4 +595,204 @@ export type Optional = Omit & Partial>; export type RequiredFields = T & Required>; -export type PartialExcept = Partial & Pick; \ No newline at end of file +export type PartialExcept = Partial & Pick; + +// ============================================================================ +// EXPRESS REQUEST TYPES (React Native compatible - no Express import) +// ============================================================================ + +export interface AuthenticatedRequest { + userId: string; + user?: { + id: string; + username: string; + isAdmin: boolean; + }; +} + +// ============================================================================ +// GITHUB API TYPES +// ============================================================================ + +export interface GitHubAsset { + id: number; + name: string; + size: number; + download_count: number; + browser_download_url: string; +} + +export interface GitHubRelease { + id: number; + tag_name: string; + name: string; + body: string; + published_at: string; + html_url: string; + assets: GitHubAsset[]; + prerelease: boolean; + draft: boolean; +} + +export interface GitHubAPIResponse { + data: T; + cached: boolean; + cache_age?: number; + timestamp?: number; +} + +// ============================================================================ +// CACHE TYPES +// ============================================================================ + +export interface CacheEntry { + data: T; + timestamp: number; + expiresAt: number; +} + +// ============================================================================ +// DATABASE EXPORT/IMPORT TYPES +// ============================================================================ + +export interface ExportSummary { + sshHostsImported: number; + sshCredentialsImported: number; + fileManagerItemsImported: number; + dismissedAlertsImported: number; + credentialUsageImported: number; + settingsImported: number; + skippedItems: number; + errors: string[]; +} + +export interface ImportResult { + success: boolean; + summary: ExportSummary; +} + +export interface ExportRequestBody { + password: string; +} + +export interface ImportRequestBody { + password: string; +} + +export interface ExportPreviewBody { + scope?: string; + includeCredentials?: boolean; +} + +export interface RestoreRequestBody { + backupPath: string; + targetPath?: string; +} + +// ============================================================================ +// SERVER METRICS TYPES +// ============================================================================ + +export interface CpuMetrics { + percent: number | null; + cores: number | null; + load: [number, number, number] | null; +} + +export interface MemoryMetrics { + percent: number | null; + usedGiB: number | null; + totalGiB: number | null; +} + +export interface DiskMetrics { + percent: number | null; + usedHuman: string | null; + totalHuman: string | null; +} + +export interface ServerMetrics { + cpu: CpuMetrics; + memory: MemoryMetrics; + disk: DiskMetrics; + lastChecked: string; +} + +export interface ServerStatus { + status: "online" | "offline"; + lastChecked: string; +} + +// ============================================================================ +// AUTH TYPES +// ============================================================================ + +export interface AuthResponse { + token: string; + success?: boolean; + is_admin?: boolean; + username?: string; + userId?: string; + is_oidc?: boolean; + totp_enabled?: boolean; + data_unlocked?: boolean; + requires_totp?: boolean; + temp_token?: string; +} + +export interface UserInfo { + totp_enabled: boolean; + userId: string; + username: string; + is_admin: boolean; + is_oidc: boolean; + data_unlocked: boolean; +} + +export interface UserCount { + count: number; +} + +export interface OIDCAuthorize { + auth_url: string; +} + +// ============================================================================ +// FILE MANAGER OPERATION TYPES +// ============================================================================ + +export interface FileManagerOperation { + name: string; + path: string; + isSSH: boolean; + sshSessionId?: string; + hostId: number; +} + +// ============================================================================ +// SERVER CONFIG TYPES +// ============================================================================ + +export interface ServerConfig { + serverUrl: string; + lastUpdated: string; +} + +// ============================================================================ +// HOMEPAGE TYPES +// ============================================================================ + +export interface UptimeInfo { + uptimeMs: number; + uptimeSeconds: number; + formatted: string; +} + +export interface RecentActivityItem { + id: number; + userId: string; + type: "terminal" | "file_manager"; + hostId: number; + hostName: string; + timestamp: string; +}