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 { Stack, useLocalSearchParams, useRouter, useFocusEffect, } from 'expo-router'; import React, { createContext, startTransition, useEffect, useRef, useState, } from 'react'; import { KeyboardAvoidingView, Pressable, Text, View } from 'react-native'; import { useSshStore } from '@/lib/ssh-store'; import { useTheme } from '@/lib/theme'; import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing'; import { useContextSafe } from '@/lib/utils'; type IconName = keyof typeof Ionicons.glyphMap; export default function TabsShellDetail() { const [ready, setReady] = useState(false); useFocusEffect( React.useCallback(() => { startTransition(() => { setTimeout(() => { // TODO: This is gross. It would be much better to switch // after the navigation animation completes. setReady(true); }, 16); }); return () => { setReady(false); }; }, []), ); if (!ready) return ; return ; } function RouteSkeleton() { const theme = useTheme(); return ( Loading ); } const encoder = new TextEncoder(); function ShellDetail() { const xtermRef = useRef(null); const terminalReadyRef = useRef(false); const listenerIdRef = useRef(null); const searchParams = useLocalSearchParams<{ connectionId?: string; channelId?: string; }>(); if (!searchParams.connectionId || !searchParams.channelId) throw new Error('Missing connectionId or channelId'); const connectionId = searchParams.connectionId; const channelId = parseInt(searchParams.channelId); const router = useRouter(); const theme = useTheme(); const shell = useSshStore( (s) => s.shells[`${connectionId}-${channelId}` as const], ); const connection = useSshStore((s) => s.connections[connectionId]); useEffect(() => { if (shell && connection) return; console.log('shell or connection not found, replacing route with /shell'); router.back(); }, [connection, router, shell]); useEffect(() => { const xterm = xtermRef.current; return () => { if (shell && listenerIdRef.current != null) shell.removeListener(listenerIdRef.current); listenerIdRef.current = null; if (xterm) xterm.flush(); }; }, [shell]); const marginBottom = useBottomTabSpacing(); return ( <> ( { if (!connection) return; try { await connection.disconnect(); } catch (e) { console.warn('Failed to disconnect', e); } }} > ), }} /> { if (terminalReadyRef.current) return; terminalReadyRef.current = true; if (!shell) throw new Error('Shell not found'); // Replay from head, then attach live listener void (async () => { const res = 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.map((c) => new Uint8Array(c))); 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(new Uint8Array(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(); }} onData={(terminalMessage) => { if (!shell) return; const bytes = encoder.encode(terminalMessage); shell.sendData(bytes.buffer).catch((e: unknown) => { console.warn('sendData failed', e); router.back(); }); }} /> { if (!shell) return; shell.sendData(bytes.buffer).catch((e: unknown) => { console.warn('sendData failed', e); router.back(); }); }} /> {/* */} ); } type KeyboardToolbarProps = { sendBytes: (bytes: Uint8Array) => void; }; const KeyboardToolBarContext = createContext(null); function KeyboardToolbar(props: KeyboardToolbarProps) { return ( ); } function KeyboardToolbarRow({ children }: { children?: React.ReactNode }) { return {children}; } type KeyboardToolbarButtonPresetType = | 'esc' | '/' | '|' | 'home' | 'up' | 'end' | 'pgup' | 'pgdn' | 'fn' | 'tab' | 'ctrl' | 'alt' | 'left' | 'down' | 'right' | 'insert' | 'delete' | 'pageup' | 'pagedown' | 'fn'; function KeyboardToolbarButtonPreset({ preset, }: { preset: KeyboardToolbarButtonPresetType; }) { return ( ); } const keyboardToolbarButtonPresetToProps: Record< KeyboardToolbarButtonPresetType, KeyboardToolbarButtonProps > = { esc: { label: 'ESC', sendBytes: new Uint8Array([27]) }, '/': { label: '/', sendBytes: new Uint8Array([47]) }, '|': { label: '|', sendBytes: new Uint8Array([124]) }, home: { label: 'HOME', sendBytes: new Uint8Array([27, 91, 72]) }, end: { label: 'END', sendBytes: new Uint8Array([27, 91, 70]) }, pgup: { label: 'PGUP', sendBytes: new Uint8Array([27, 91, 53, 126]) }, pgdn: { label: 'PGDN', sendBytes: new Uint8Array([27, 91, 54, 126]) }, fn: { label: 'FN', isModifier: true }, tab: { label: 'TAB', sendBytes: new Uint8Array([9]) }, ctrl: { label: 'CTRL', isModifier: true }, alt: { label: 'ALT', isModifier: true }, left: { iconName: 'arrow-back', sendBytes: new Uint8Array([27, 91, 68]) }, up: { iconName: 'arrow-up', sendBytes: new Uint8Array([27, 91, 65]) }, down: { iconName: 'arrow-down', sendBytes: new Uint8Array([27, 91, 66]) }, right: { iconName: 'arrow-forward', sendBytes: new Uint8Array([27, 91, 67]), }, insert: { label: 'INSERT', sendBytes: new Uint8Array([27, 91, 50, 126]) }, delete: { label: 'DELETE', sendBytes: new Uint8Array([27, 91, 51, 126]) }, pageup: { label: 'PAGEUP', sendBytes: new Uint8Array([27, 91, 53, 126]) }, pagedown: { label: 'PAGEDOWN', sendBytes: new Uint8Array([27, 91, 54, 126]) }, }; type KeyboardToolbarButtonProps = ( | { isModifier: true; } | { sendBytes: Uint8Array; } ) & ( | { label: string; } | { iconName: IconName; } ); function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) { const theme = useTheme(); const [modifierActive, setModifierActive] = useState(false); const { sendBytes } = useContextSafe(KeyboardToolBarContext); const isTextLabel = 'label' in props; const children = isTextLabel ? ( {props.label} ) : ( ); return ( { console.log('button pressed'); if ('isModifier' in props && props.isModifier) { setModifierActive((active) => !active); } else if ('sendBytes' in props) { // todo: send key press sendBytes(new Uint8Array(props.sendBytes)); } }} style={[ { flex: 1, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: theme.colors.border, }, modifierActive && { backgroundColor: theme.colors.primary }, ]} > {children} ); }