import { Ionicons } from '@expo/vector-icons'; import { type ListenerEvent } from '@fressh/react-native-uniffi-russh'; import { XtermJsWebView, type XtermWebViewHandle, } from '@fressh/react-native-xtermjs-webview'; import { useQueryClient } from '@tanstack/react-query'; import { Stack, useLocalSearchParams, useRouter, useFocusEffect, } from 'expo-router'; import React, { startTransition, useEffect, useRef, useState } from 'react'; import { Dimensions, Platform, Pressable, Text, View } from 'react-native'; import { SafeAreaView, useSafeAreaInsets, } from 'react-native-safe-area-context'; import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns'; import { useSshStore, makeSessionKey } from '@/lib/ssh-store'; import { useTheme } from '@/lib/theme'; export default function TabsShellDetail() { const [ready, setReady] = useState(false); useFocusEffect( React.useCallback(() => { startTransition(() => { setReady(true); }); // React 19: non-urgent return () => { setReady(false); }; }, []), ); if (!ready) return ; return ; } function RouteSkeleton() { return ( Loading ); } function ShellDetail() { const xtermRef = useRef(null); const terminalReadyRef = useRef(false); // Legacy buffer no longer used; relying on Rust ring for replay const listenerIdRef = useRef(null); const { connectionId, channelId } = useLocalSearchParams<{ connectionId?: string; channelId?: string; }>(); const router = useRouter(); const theme = useTheme(); const channelIdNum = Number(channelId); const sess = useSshStore((s) => connectionId && channelId ? s.getByKey(makeSessionKey(connectionId, channelIdNum)) : undefined, ); const connection = sess?.connection; const shell = sess?.shell; // If the shell disconnects, leave this screen to the list view useEffect(() => { if (!sess) return; if (sess.status === 'disconnected') { // Replace so the detail screen isn't on the stack anymore router.replace('/shell'); } }, [router, sess]); // SSH -> xterm: on initialized, replay ring head then attach live listener useEffect(() => { const xterm = xtermRef.current; return () => { if (shell && listenerIdRef.current != null) shell.removeListener(listenerIdRef.current); listenerIdRef.current = null; if (xterm) xterm.flush(); }; }, [shell]); const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const estimatedTabBarHeight = Platform.select({ ios: 49, android: 80, default: 56, }); const windowH = Dimensions.get('window').height; const computeBottomExtra = (y: number, height: number) => { const extra = windowH - (y + height); return extra > 0 ? extra : 0; }; // Measure any bottom overlap (e.g., native tab bar) and add padding to avoid it const [bottomExtra, setBottomExtra] = useState(0); return ( { const { y, height } = e.nativeEvent.layout; const extra = computeBottomExtra(y, height); if (extra !== bottomExtra) setBottomExtra(extra); }} style={{ flex: 1, backgroundColor: theme.colors.background, padding: 12, paddingBottom: 12 + insets.bottom + (bottomExtra || estimatedTabBarHeight), }} > ( { if (!connection) return; try { await disconnectSshConnectionAndInvalidateQuery({ connectionId: connection.connectionId, queryClient, }); } catch (e) { console.warn('Failed to disconnect', e); } router.replace('/shell'); }} > ), }} /> { console.log('WebView render process gone -> clear()'); const xr = xtermRef.current; if (xr) xr.clear(); }} onContentProcessDidTerminate={() => { console.log('WKWebView content process terminated -> clear()'); const xr = xtermRef.current; if (xr) xr.clear(); }} onLoadEnd={() => { console.log('WebView onLoadEnd'); }} onMessage={(m) => { console.log('received msg', m); if (m.type === 'initialized') { if (terminalReadyRef.current) return; terminalReadyRef.current = true; // Replay from head, then attach live listener if (shell) { void (async () => { const res = await shell.readBuffer({ mode: 'head' }); console.log('readBuffer(head)', { chunks: res.chunks.length, nextSeq: res.nextSeq, dropped: res.dropped, }); if (res.chunks.length) { const chunks = res.chunks.map((c) => c.bytes); const xr = xtermRef.current; if (xr) { xr.writeMany(chunks); xr.flush(); } } const id = shell.addListener( (ev: ListenerEvent) => { if ('kind' in ev) { console.log('listener.dropped', ev); return; } const chunk = ev; const xr3 = xtermRef.current; if (xr3) xr3.write(chunk.bytes); }, { cursor: { mode: 'seq', seq: res.nextSeq } }, ); console.log('shell listener attached', id.toString()); listenerIdRef.current = id; })(); } // Focus to pop the keyboard (iOS needs the prop we set) const xr2 = xtermRef.current; if (xr2) xr2.focus(); return; } if (m.type === 'data') { console.log('xterm->SSH', { len: m.data.length }); const { buffer, byteOffset, byteLength } = m.data; const ab = buffer.slice(byteOffset, byteOffset + byteLength); if (shell) { shell.sendData(ab as ArrayBuffer).catch((e: unknown) => { console.warn('sendData failed', e); router.back(); }); } return; } else { console.log('xterm.debug', m.message); } }} /> ); }