diff --git a/src/main.tsx b/src/main.tsx index 7e6d45dc..655c03b7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,25 +1,68 @@ -import {StrictMode} from 'react' +import {StrictMode, useEffect, useState, useRef} from 'react' import {createRoot} from 'react-dom/client' -import { useMediaQuery } from "react-responsive"; import './index.css' import DesktopApp from './ui/Desktop/DesktopApp.tsx' -import MobileApp from './ui/Mobile/MobileApp.tsx' +import { MobileApp } from './ui/Mobile/MobileApp.tsx' import {ThemeProvider} from "@/components/theme-provider" import './i18n/i18n' +function useWindowWidth() { + const [width, setWidth] = useState(window.innerWidth); + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + const lastSwitchTime = useRef(0); + const isCurrentlyMobile = useRef(window.innerWidth < 768); + const hasSwitchedOnce = useRef(false); -function RootApp() { - const isMobile = useMediaQuery({ maxWidth: 767 }); + useEffect(() => { + let timeoutId: NodeJS.Timeout; + const handleResize = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + const newWidth = window.innerWidth; + const newIsMobile = newWidth < 768; + const now = Date.now(); + + // If we've already switched once, don't switch again for a very long time + if (hasSwitchedOnce.current && (now - lastSwitchTime.current) < 10000) { + setWidth(newWidth); + return; + } + + // Only switch if we're actually crossing the threshold AND enough time has passed + if (newIsMobile !== isCurrentlyMobile.current && (now - lastSwitchTime.current) > 5000) { + lastSwitchTime.current = now; + isCurrentlyMobile.current = newIsMobile; + hasSwitchedOnce.current = true; + setWidth(newWidth); + setIsMobile(newIsMobile); + } else { + setWidth(newWidth); + } + }, 2000); // Even longer debounce + }; + window.addEventListener("resize", handleResize); - return ( - - {isMobile ? : } - - ); + return () => { + clearTimeout(timeoutId); + window.removeEventListener("resize", handleResize); + }; + }, []); + + return width; } -createRoot(document.getElementById("root")!).render( +function RootApp() { + const width = useWindowWidth(); + const isMobile = width < 768; + + // Use a stable key to prevent unnecessary remounting + return isMobile ? : ; +} + +createRoot(document.getElementById('root')!).render( - - -); \ No newline at end of file + + + + , +) diff --git a/src/ui/Desktop/DesktopApp.tsx b/src/ui/Desktop/DesktopApp.tsx index fb72cd8a..d199f7bf 100644 --- a/src/ui/Desktop/DesktopApp.tsx +++ b/src/ui/Desktop/DesktopApp.tsx @@ -1,6 +1,6 @@ import React, {useState, useEffect} from "react" import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx" -import {Homepage} from "@/ui/Homepage/Homepage.tsx" +import {Homepage} from "@/ui/Desktop/Homepage/Homepage.tsx" import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx" import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx" import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx" diff --git a/src/ui/Desktop/Homepage/Homepage.tsx b/src/ui/Desktop/Homepage/Homepage.tsx index c563e4a0..9a8bd6f6 100644 --- a/src/ui/Desktop/Homepage/Homepage.tsx +++ b/src/ui/Desktop/Homepage/Homepage.tsx @@ -1,7 +1,7 @@ import React, {useEffect, useState} from "react"; -import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx"; -import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx"; -import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx"; +import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx"; +import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx"; +import {HomepageAlertManager} from "@/ui/Desktop/Homepage/HomepageAlertManager.tsx"; import {Button} from "@/components/ui/button.tsx"; import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts"; import {useTranslation} from "react-i18next"; diff --git a/src/ui/Desktop/Homepage/HomepageAuth.tsx b/src/ui/Desktop/Homepage/HomepageAuth.tsx index 5b1ca837..65c04a43 100644 --- a/src/ui/Desktop/Homepage/HomepageAuth.tsx +++ b/src/ui/Desktop/Homepage/HomepageAuth.tsx @@ -1,11 +1,11 @@ import React, {useState, useEffect} from "react"; -import {cn} from "../../lib/utils.ts"; -import {Button} from "../../components/ui/button.tsx"; -import {Input} from "../../components/ui/input.tsx"; -import {Label} from "../../components/ui/label.tsx"; -import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx"; +import {cn} from "@/lib/utils.ts"; +import {Button} from "@/components/ui/button.tsx"; +import {Input} from "@/components/ui/input.tsx"; +import {Label} from "@/components/ui/label.tsx"; +import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx"; import {useTranslation} from "react-i18next"; -import {LanguageSwitcher} from "../../components/LanguageSwitcher"; +import {LanguageSwitcher} from "@/components/LanguageSwitcher.tsx"; import { registerUser, loginUser, @@ -18,7 +18,7 @@ import { completePasswordReset, getOIDCAuthorizeUrl, verifyTOTPLogin -} from "../main-axios.ts"; +} from "../../main-axios.ts"; function setCookie(name: string, value: string, days = 7) { const expires = new Date(Date.now() + days * 864e5).toUTCString(); diff --git a/src/ui/Mobile/Apps/Terminal/Terminal.tsx b/src/ui/Mobile/Apps/Terminal/Terminal.tsx index e69de29b..7d81ab24 100644 --- a/src/ui/Mobile/Apps/Terminal/Terminal.tsx +++ b/src/ui/Mobile/Apps/Terminal/Terminal.tsx @@ -0,0 +1,406 @@ +import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react'; +import {useXTerm} from 'react-xtermjs'; +import {FitAddon} from '@xterm/addon-fit'; +import {ClipboardAddon} from '@xterm/addon-clipboard'; +import {Unicode11Addon} from '@xterm/addon-unicode11'; +import {WebLinksAddon} from '@xterm/addon-web-links'; +import {useTranslation} from 'react-i18next'; + +declare global { + interface Window { + mobileTerminalInitialized?: boolean; + mobileTerminalWebSocket?: WebSocket | null; + mobileTerminalPingInterval?: NodeJS.Timeout | null; + } +} + +interface SSHTerminalProps { + hostConfig: any; + isVisible: boolean; + title?: string; +} + +export const Terminal = forwardRef(function SSHTerminal( + {hostConfig, isVisible}, + ref +) { + const {t} = useTranslation(); + const {instance: terminal, ref: xtermRef} = useXTerm(); + const fitAddonRef = useRef(null); + const webSocketRef = useRef(null); + const resizeTimeout = useRef(null); + const wasDisconnectedBySSH = useRef(false); + const pingIntervalRef = useRef(null); + const [visible, setVisible] = useState(false); + const isVisibleRef = useRef(false); + const lastHostConfigRef = useRef(null); + const isInitializedRef = useRef(false); + const terminalInstanceRef = useRef(null); + + const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); + const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); + const notifyTimerRef = useRef(null); + const DEBOUNCE_MS = 140; + + useEffect(() => { + isVisibleRef.current = isVisible; + }, [isVisible]); + + function hardRefresh() { + try { + if (terminal && typeof (terminal as any).refresh === 'function') { + (terminal as any).refresh(0, terminal.rows - 1); + } + } catch (_) { + } + } + + function scheduleNotify(cols: number, rows: number) { + if (!(cols > 0 && rows > 0)) return; + pendingSizeRef.current = {cols, rows}; + if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); + notifyTimerRef.current = setTimeout(() => { + const next = pendingSizeRef.current; + const last = lastSentSizeRef.current; + if (!next) return; + if (last && last.cols === next.cols && last.rows === next.rows) return; + if (webSocketRef.current?.readyState === WebSocket.OPEN) { + webSocketRef.current.send(JSON.stringify({type: 'resize', data: next})); + lastSentSizeRef.current = next; + } + }, DEBOUNCE_MS); + } + + useImperativeHandle(ref, () => ({ + disconnect: () => { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + webSocketRef.current?.close(); + }, + fit: () => { + fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + }, + sendInput: (data: string) => { + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send(JSON.stringify({type: 'input', data})); + } + }, + notifyResize: () => { + try { + const cols = terminal?.cols ?? undefined; + const rows = terminal?.rows ?? undefined; + if (typeof cols === 'number' && typeof rows === 'number') { + scheduleNotify(cols, rows); + hardRefresh(); + } + } catch (_) { + } + }, + refresh: () => hardRefresh(), + }), [terminal]); + + useEffect(() => { + window.addEventListener('resize', handleWindowResize); + return () => window.removeEventListener('resize', handleWindowResize); + }, []); + + function handleWindowResize() { + if (!isVisibleRef.current) return; + fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + } + + function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) { + ws.addEventListener('open', () => { + ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); + terminal.onData((data) => { + ws.send(JSON.stringify({type: 'input', data})); + }); + + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({type: 'ping'})); + } + }, 30000); + window.mobileTerminalPingInterval = pingIntervalRef.current; + }); + + ws.addEventListener('message', (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'data') terminal.write(msg.data); + else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`); + else if (msg.type === 'connected') { + } else if (msg.type === 'disconnected') { + wasDisconnectedBySSH.current = true; + terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`); + } + } catch (error) { + } + }); + + ws.addEventListener('close', () => { + if (!wasDisconnectedBySSH.current) { + terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`); + } + }); + + ws.addEventListener('error', () => { + terminal.writeln(`\r\n[${t('terminal.connectionError')}]`); + }); + } + + useEffect(() => { + if (!terminal || !xtermRef.current || !hostConfig) return; + + if (window.mobileTerminalInitialized) { + webSocketRef.current = window.mobileTerminalWebSocket || null; + pingIntervalRef.current = window.mobileTerminalPingInterval || null; + + terminal.options = { + cursorBlink: true, + cursorStyle: 'bar', + scrollback: 10000, + fontSize: 14, + fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace', + theme: {background: '#18181b', foreground: '#f7f7f7'}, + allowTransparency: true, + convertEol: true, + windowsMode: false, + macOptionIsMeta: false, + macOptionClickForcesSelection: false, + rightClickSelectsWord: false, + fastScrollModifier: 'alt', + fastScrollSensitivity: 5, + allowProposedApi: true, + }; + + const fitAddon = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + const unicode11Addon = new Unicode11Addon(); + const webLinksAddon = new WebLinksAddon(); + + fitAddonRef.current = fitAddon; + terminal.loadAddon(fitAddon); + terminal.loadAddon(clipboardAddon); + terminal.loadAddon(unicode11Addon); + terminal.loadAddon(webLinksAddon); + terminal.open(xtermRef.current); + + const resizeObserver = new ResizeObserver(() => { + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + resizeTimeout.current = setTimeout(() => { + if (!isVisibleRef.current) return; + fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + }, 100); + }); + + resizeObserver.observe(xtermRef.current); + + setTimeout(() => { + fitAddon.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + setVisible(true); + terminal.focus(); + }, 100); + + return () => { + resizeObserver.disconnect(); + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + }; + } + + window.mobileTerminalInitialized = true; + isInitializedRef.current = true; + terminalInstanceRef.current = terminal; + lastHostConfigRef.current = hostConfig; + + terminal.options = { + cursorBlink: true, + cursorStyle: 'bar', + scrollback: 10000, + fontSize: 14, + fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace', + theme: {background: '#18181b', foreground: '#f7f7f7'}, + allowTransparency: true, + convertEol: true, + windowsMode: false, + macOptionIsMeta: false, + macOptionClickForcesSelection: false, + rightClickSelectsWord: false, + fastScrollModifier: 'alt', + fastScrollSensitivity: 5, + allowProposedApi: true, + }; + + const fitAddon = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + const unicode11Addon = new Unicode11Addon(); + const webLinksAddon = new WebLinksAddon(); + + fitAddonRef.current = fitAddon; + terminal.loadAddon(fitAddon); + terminal.loadAddon(clipboardAddon); + terminal.loadAddon(unicode11Addon); + terminal.loadAddon(webLinksAddon); + terminal.open(xtermRef.current); + + const resizeObserver = new ResizeObserver(() => { + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + resizeTimeout.current = setTimeout(() => { + if (!isVisibleRef.current) return; + fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + }, 100); + }); + + resizeObserver.observe(xtermRef.current); + + const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve(); + readyFonts.then(() => { + setTimeout(() => { + fitAddon.fit(); + setTimeout(() => { + fitAddon.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + setVisible(true); + terminal.focus(); + }, 0); + + const cols = terminal.cols; + const rows = terminal.rows; + + const isDev = process.env.NODE_ENV === 'development' && + (window.location.port === '3000' || window.location.port === '5173' || window.location.port === ''); + + const wsUrl = isDev + ? 'ws://localhost:8082' + : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; + + const ws = new WebSocket(wsUrl); + webSocketRef.current = ws; + window.mobileTerminalWebSocket = ws; + wasDisconnectedBySSH.current = false; + + setupWebSocketListeners(ws, cols, rows); + }, 300); + }); + + return () => { + resizeObserver.disconnect(); + if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + }; + }, [xtermRef, terminal, hostConfig]); + + useEffect(() => { + if (isVisible && fitAddonRef.current) { + setTimeout(() => { + fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + terminal.focus(); + }, 0); + } + }, [isVisible, terminal]); + + useEffect(() => { + if (!fitAddonRef.current) return; + setTimeout(() => { + fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + if (terminal && isVisible) { + terminal.focus(); + } + }, 0); + }, [isVisible, terminal]); + + return ( +
{ + terminal.focus(); + }} + /> + ); +}); + +const style = document.createElement('style'); +style.innerHTML = ` +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap'); + +/* Load NerdFonts locally */ +@font-face { + font-family: 'JetBrains Mono Nerd Font'; + src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono Nerd Font'; + src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype'); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono Nerd Font'; + src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype'); + font-weight: normal; + font-style: italic; + font-display: swap; +} + +.xterm .xterm-viewport::-webkit-scrollbar { + width: 8px; + background: transparent; +} +.xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(180,180,180,0.7); + border-radius: 4px; +} +.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: rgba(120,120,120,0.9); +} +.xterm .xterm-viewport { + scrollbar-width: thin; + scrollbar-color: rgba(180,180,180,0.7) transparent; +} + +.xterm { + font-feature-settings: "liga" 1, "calt" 1; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.xterm .xterm-screen { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important; + font-variant-ligatures: contextual; +} + +.xterm .xterm-screen .xterm-char { + font-feature-settings: "liga" 1, "calt" 1; +} + +.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] { + font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; +} +`; +document.head.appendChild(style); \ No newline at end of file diff --git a/src/ui/Mobile/MobileApp.tsx b/src/ui/Mobile/MobileApp.tsx index a4b05986..8b04071e 100644 --- a/src/ui/Mobile/MobileApp.tsx +++ b/src/ui/Mobile/MobileApp.tsx @@ -1,7 +1,14 @@ +import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx"; + export function MobileApp() { return ( -
-

Mobile

+
+
) } \ No newline at end of file