mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
411 lines
10 KiB
TypeScript
411 lines
10 KiB
TypeScript
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 <RouteSkeleton />;
|
|
return <ShellDetail />;
|
|
}
|
|
|
|
function RouteSkeleton() {
|
|
const theme = useTheme();
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: theme.colors.background,
|
|
}}
|
|
>
|
|
<Text style={{ color: theme.colors.textPrimary, fontSize: 20 }}>
|
|
Loading
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
function ShellDetail() {
|
|
const xtermRef = useRef<XtermWebViewHandle>(null);
|
|
const terminalReadyRef = useRef(false);
|
|
const listenerIdRef = useRef<bigint | null>(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 (
|
|
<>
|
|
<View
|
|
style={{
|
|
justifyContent: 'flex-start',
|
|
backgroundColor: theme.colors.background,
|
|
paddingTop: 2,
|
|
paddingLeft: 8,
|
|
paddingRight: 8,
|
|
paddingBottom: 0,
|
|
marginBottom,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<Stack.Screen
|
|
options={{
|
|
headerBackVisible: true,
|
|
headerRight: () => (
|
|
<Pressable
|
|
accessibilityLabel="Disconnect"
|
|
hitSlop={10}
|
|
onPress={async () => {
|
|
if (!connection) return;
|
|
try {
|
|
await connection.disconnect();
|
|
} catch (e) {
|
|
console.warn('Failed to disconnect', e);
|
|
}
|
|
}}
|
|
>
|
|
<Ionicons name="power" size={20} color={theme.colors.primary} />
|
|
</Pressable>
|
|
),
|
|
}}
|
|
/>
|
|
<KeyboardAvoidingView
|
|
behavior="height"
|
|
keyboardVerticalOffset={120}
|
|
style={{ flex: 1, gap: 4 }}
|
|
>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
borderWidth: 2,
|
|
borderColor: theme.colors.border,
|
|
}}
|
|
>
|
|
<XtermJsWebView
|
|
ref={xtermRef}
|
|
style={{ flex: 1 }}
|
|
webViewOptions={{
|
|
// Prevent iOS from adding automatic top inset inside WebView
|
|
contentInsetAdjustmentBehavior: 'never',
|
|
}}
|
|
logger={{
|
|
log: console.log,
|
|
// debug: console.log,
|
|
warn: console.warn,
|
|
error: console.error,
|
|
}}
|
|
// xterm options
|
|
xtermOptions={{
|
|
theme: {
|
|
background: theme.colors.background,
|
|
foreground: theme.colors.textPrimary,
|
|
},
|
|
}}
|
|
onInitialized={() => {
|
|
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();
|
|
});
|
|
}}
|
|
/>
|
|
</View>
|
|
<KeyboardToolbar
|
|
sendBytes={(bytes) => {
|
|
if (!shell) return;
|
|
shell.sendData(bytes.buffer).catch((e: unknown) => {
|
|
console.warn('sendData failed', e);
|
|
router.back();
|
|
});
|
|
}}
|
|
/>
|
|
</KeyboardAvoidingView>
|
|
</View>
|
|
{/* <KeyboardToolbar
|
|
offset={{
|
|
opened: -80,
|
|
}}
|
|
/> */}
|
|
</>
|
|
);
|
|
}
|
|
|
|
type KeyboardToolbarProps = {
|
|
sendBytes: (bytes: Uint8Array<ArrayBuffer>) => void;
|
|
};
|
|
const KeyboardToolBarContext = createContext<KeyboardToolbarProps | null>(null);
|
|
|
|
function KeyboardToolbar(props: KeyboardToolbarProps) {
|
|
return (
|
|
<KeyboardToolBarContext value={props}>
|
|
<View
|
|
style={{
|
|
height: 100,
|
|
}}
|
|
>
|
|
<KeyboardToolbarRow>
|
|
<KeyboardToolbarButtonPreset preset="esc" />
|
|
<KeyboardToolbarButtonPreset preset="/" />
|
|
<KeyboardToolbarButtonPreset preset="|" />
|
|
<KeyboardToolbarButtonPreset preset="home" />
|
|
<KeyboardToolbarButtonPreset preset="up" />
|
|
<KeyboardToolbarButtonPreset preset="end" />
|
|
<KeyboardToolbarButtonPreset preset="pgup" />
|
|
</KeyboardToolbarRow>
|
|
<KeyboardToolbarRow>
|
|
<KeyboardToolbarButtonPreset preset="tab" />
|
|
<KeyboardToolbarButtonPreset preset="ctrl" />
|
|
<KeyboardToolbarButtonPreset preset="alt" />
|
|
<KeyboardToolbarButtonPreset preset="left" />
|
|
<KeyboardToolbarButtonPreset preset="down" />
|
|
<KeyboardToolbarButtonPreset preset="right" />
|
|
<KeyboardToolbarButtonPreset preset="pgdn" />
|
|
</KeyboardToolbarRow>
|
|
</View>
|
|
</KeyboardToolBarContext>
|
|
);
|
|
}
|
|
|
|
function KeyboardToolbarRow({ children }: { children?: React.ReactNode }) {
|
|
return <View style={{ flexDirection: 'row', flex: 1 }}>{children}</View>;
|
|
}
|
|
|
|
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 (
|
|
<KeyboardToolbarButton {...keyboardToolbarButtonPresetToProps[preset]} />
|
|
);
|
|
}
|
|
|
|
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 ? (
|
|
<Text style={{ color: theme.colors.textPrimary }}>{props.label}</Text>
|
|
) : (
|
|
<Ionicons
|
|
name={props.iconName}
|
|
size={20}
|
|
color={theme.colors.textPrimary}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={() => {
|
|
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}
|
|
</Pressable>
|
|
);
|
|
}
|