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}
);
}