mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
some things working
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@fressh/assets": "workspace:*",
|
||||
"@fressh/react-native-uniffi-russh": "workspace:*",
|
||||
"@fressh/react-native-xtermjs-webview": "workspace:*",
|
||||
"@react-native-segmented-control/segmented-control": "2.5.7",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.4",
|
||||
@@ -48,13 +49,13 @@
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.8",
|
||||
"expo-linking": "~8.0.8",
|
||||
"@fressh/react-native-xtermjs-webview": "workspace:*",
|
||||
"expo-router": "6.0.6",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"p-queue": "^8.1.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
|
||||
@@ -5,22 +5,15 @@ import {
|
||||
type XtermWebViewHandle,
|
||||
} from '@fressh/react-native-xtermjs-webview';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import PQueue from 'p-queue';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Pressable, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
const renderer: 'xtermjs' | 'rn-text' = 'xtermjs';
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
export default function TabsShellDetail() {
|
||||
return <ShellDetail />;
|
||||
}
|
||||
@@ -43,29 +36,42 @@ function ShellDetail() {
|
||||
? RnRussh.getSshShell(String(connectionId), channelIdNum)
|
||||
: undefined;
|
||||
|
||||
const [shellData, setShellData] = useState('');
|
||||
function sendDataToXterm(data: ArrayBuffer) {
|
||||
try {
|
||||
const bytes = new Uint8Array(data);
|
||||
console.log('sendDataToXterm', new TextDecoder().decode(bytes));
|
||||
xtermWebViewRef.current?.write(bytes);
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode shell data', e);
|
||||
}
|
||||
}
|
||||
|
||||
const queueRef = useRef<PQueue>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
if (!queueRef.current)
|
||||
queueRef.current = new PQueue({
|
||||
concurrency: 1,
|
||||
intervalCap: 1, // <= one task per interval
|
||||
interval: 100, // <= 100ms between tasks
|
||||
autoStart: false, // <= buffer until we start()
|
||||
});
|
||||
const xtermQueue = queueRef.current;
|
||||
if (!connection || !xtermQueue) return;
|
||||
const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
|
||||
try {
|
||||
const bytes = new Uint8Array(data);
|
||||
xtermWebViewRef.current?.write(bytes);
|
||||
const chunk = decoder.decode(bytes);
|
||||
setShellData((prev) => prev + chunk);
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode shell data', e);
|
||||
}
|
||||
console.log('ssh.onData', new TextDecoder().decode(new Uint8Array(data)));
|
||||
void xtermQueue.add(() => {
|
||||
sendDataToXterm(data);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
connection.removeChannelListener(listenerId);
|
||||
xtermQueue.pause();
|
||||
xtermQueue.clear();
|
||||
};
|
||||
}, [connection]);
|
||||
}, [connection, queueRef]);
|
||||
|
||||
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, [shellData]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
@@ -77,9 +83,15 @@ function ShellDetail() {
|
||||
accessibilityLabel="Disconnect"
|
||||
hitSlop={10}
|
||||
onPress={async () => {
|
||||
if (!connection) return;
|
||||
try {
|
||||
await connection?.disconnect();
|
||||
} catch {}
|
||||
await disconnectSshConnectionAndInvalidateQuery({
|
||||
connectionId: connection.connectionId,
|
||||
queryClient: queryClient,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to disconnect', e);
|
||||
}
|
||||
router.replace('/shell');
|
||||
}}
|
||||
>
|
||||
@@ -94,140 +106,30 @@ function ShellDetail() {
|
||||
{ backgroundColor: theme.colors.background },
|
||||
]}
|
||||
>
|
||||
<ScrollView>
|
||||
{renderer === 'xtermjs' ? (
|
||||
<XtermJsWebView
|
||||
ref={xtermWebViewRef}
|
||||
style={{ flex: 1, height: 400 }}
|
||||
// textZoom={0}
|
||||
// injectedJavaScript={`
|
||||
// setTimeout(() => {
|
||||
// document.body.style.backgroundColor = '${theme.colors.background}';
|
||||
// document.body.style.color = '${theme.colors.textPrimary}';
|
||||
// document.body.style.fontSize = '80px';
|
||||
// const termDiv = document.getElementById('terminal');
|
||||
// termDiv.style.backgroundColor = '${theme.colors.background}';
|
||||
// termDiv.style.color = '${theme.colors.textPrimary}';
|
||||
// window.terminal.options.fontSize = 50;
|
||||
// }, 50);
|
||||
// `}
|
||||
onMessage={(event) => {
|
||||
console.log('onMessage', event.nativeEvent.data);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#0E172B',
|
||||
borderRadius: 12,
|
||||
height: 400,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 12,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text
|
||||
selectable
|
||||
style={{
|
||||
color: '#D1D5DB',
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
fontFamily: Platform.select({
|
||||
ios: 'Menlo',
|
||||
android: 'monospace',
|
||||
default: 'monospace',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{shellData || 'Connected. Output will appear here...'}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
<CommandInput
|
||||
executeCommand={async (command) => {
|
||||
await shell?.sendData(
|
||||
Uint8Array.from(new TextEncoder().encode(command + '\n'))
|
||||
.buffer,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
<XtermJsWebView
|
||||
ref={xtermWebViewRef}
|
||||
style={{ flex: 1, height: 400 }}
|
||||
// textZoom={0}
|
||||
injectedJavaScript={`
|
||||
document.body.style.backgroundColor = '${theme.colors.background}';
|
||||
const termDiv = document.getElementById('terminal');
|
||||
window.terminal.options.fontSize = 50;
|
||||
setTimeout(() => {
|
||||
window.fitAddon?.fit();
|
||||
}, 1_000);
|
||||
`}
|
||||
onMessage={(message) => {
|
||||
if (message.type === 'initialized') {
|
||||
console.log('xterm.onMessage initialized');
|
||||
queueRef.current?.start();
|
||||
return;
|
||||
}
|
||||
const data = message.data;
|
||||
console.log('xterm.onMessage', new TextDecoder().decode(data));
|
||||
void shell?.sendData(data.buffer as ArrayBuffer);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput(props: {
|
||||
executeCommand: (command: string) => Promise<void>;
|
||||
}) {
|
||||
const [command, setCommand] = useState('');
|
||||
|
||||
async function handleExecute() {
|
||||
if (!command.trim()) return;
|
||||
await props.executeCommand(command);
|
||||
setCommand('');
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<TextInput
|
||||
testID="command-input"
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#0E172B',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
color: '#E5E7EB',
|
||||
fontSize: 16,
|
||||
fontFamily: Platform.select({
|
||||
ios: 'Menlo',
|
||||
android: 'monospace',
|
||||
default: 'monospace',
|
||||
}),
|
||||
}}
|
||||
value={command}
|
||||
onChangeText={setCommand}
|
||||
placeholder="Type a command and press Enter or Execute"
|
||||
placeholderTextColor="#9AA0A6"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleExecute}
|
||||
/>
|
||||
<Pressable
|
||||
style={[
|
||||
{
|
||||
backgroundColor: '#2563EB',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
{ marginTop: 8 },
|
||||
]}
|
||||
onPress={handleExecute}
|
||||
testID="execute-button"
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}>
|
||||
Execute
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html style="margin: 0; padding: 0; width: 100vw; height: 100vh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; width: 100%; height: 100%">
|
||||
<body style="margin: 0; padding: 0; width: 100vw; height: 100vh">
|
||||
<div
|
||||
id="terminal"
|
||||
style="margin: 0; padding: 0; width: 100%; height: 100%"
|
||||
|
||||
@@ -17,22 +17,24 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"js-base64": "^3.7.8",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@epic-web/config": "^1.21.3",
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@types/react": "~19.1.12",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"eslint": "^9.35.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.4.0",
|
||||
"@epic-web/config": "^1.21.3",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "6.3.6",
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { Base64 } from 'js-base64';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
const terminal = new Terminal();
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(document.getElementById('terminal')!);
|
||||
terminal.write('Hello from Xterm.js!');
|
||||
fitAddon.fit();
|
||||
window.terminal = terminal;
|
||||
window.fitAddon = fitAddon;
|
||||
const postMessage = (arg: string) => {
|
||||
window.ReactNativeWebView?.postMessage?.(arg);
|
||||
};
|
||||
setTimeout(() => {
|
||||
postMessage('DEBUG: set timeout');
|
||||
}, 1000);
|
||||
postMessage('initialized');
|
||||
}, 10);
|
||||
|
||||
terminal.onData((data) => {
|
||||
const base64Data = Base64.encode(data);
|
||||
postMessage(base64Data);
|
||||
});
|
||||
function terminalWriteBase64(base64Data: string) {
|
||||
try {
|
||||
postMessage(`DEBUG: terminalWriteBase64 ${base64Data}`);
|
||||
const data = new Uint8Array(Buffer.from(base64Data, 'base64'));
|
||||
postMessage(`DEBUG: terminalWriteBase64 decoded ${decoder.decode(data)}`);
|
||||
|
||||
const data = Base64.toUint8Array(base64Data);
|
||||
terminal.write(data);
|
||||
} catch (e) {
|
||||
postMessage(`DEBUG: terminalWriteBase64 error ${e}`);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
interface Window {
|
||||
terminal?: Terminal;
|
||||
fitAddon?: FitAddon;
|
||||
terminalWriteBase64?: (data: string) => void;
|
||||
ReactNativeWebView?: {
|
||||
postMessage?: (data: string) => void;
|
||||
|
||||
@@ -8,13 +8,19 @@ type StrictOmit<T, K extends keyof T> = Omit<T, K>;
|
||||
export type XtermWebViewHandle = {
|
||||
write: (data: Uint8Array) => void;
|
||||
};
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
export function XtermJsWebView({
|
||||
ref,
|
||||
onMessage,
|
||||
...props
|
||||
}: StrictOmit<ComponentProps<typeof WebView>, 'source' | 'originWhitelist'> & {
|
||||
}: StrictOmit<
|
||||
ComponentProps<typeof WebView>,
|
||||
'source' | 'originWhitelist' | 'onMessage'
|
||||
> & {
|
||||
ref: React.RefObject<XtermWebViewHandle | null>;
|
||||
onMessage?: (
|
||||
data: { type: 'data'; data: Uint8Array } | { type: 'initialized' },
|
||||
) => void;
|
||||
}) {
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
|
||||
@@ -22,17 +28,8 @@ export function XtermJsWebView({
|
||||
return {
|
||||
write: (data) => {
|
||||
const base64Data = Base64.fromUint8Array(data);
|
||||
console.log('writing rn side', {
|
||||
base64Data,
|
||||
dataLength: data.length,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'try to decode',
|
||||
decoder.decode(Base64.toUint8Array(base64Data)),
|
||||
);
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
window?.terminalWriteBase64('${base64Data}');
|
||||
window?.terminalWriteBase64?.('${base64Data}');
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -43,6 +40,15 @@ export function XtermJsWebView({
|
||||
ref={webViewRef}
|
||||
originWhitelist={['*']}
|
||||
source={{ html: htmlString }}
|
||||
onMessage={(event) => {
|
||||
const message = event.nativeEvent.data;
|
||||
if (message === 'initialized') {
|
||||
onMessage?.({ type: 'initialized' });
|
||||
return;
|
||||
}
|
||||
const data = Base64.toUint8Array(message);
|
||||
onMessage?.({ type: 'data', data });
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -130,6 +130,9 @@ importers:
|
||||
expo-system-ui:
|
||||
specifier: ~6.0.7
|
||||
version: 6.0.7(expo@54.0.8)(react-native-web@0.21.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.3)(@react-native-community/cli@20.0.2(typescript@5.9.2))(@types/react@19.1.12)(react@19.1.0))
|
||||
p-queue:
|
||||
specifier: ^8.1.1
|
||||
version: 8.1.1
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -385,9 +388,15 @@ importers:
|
||||
|
||||
packages/react-native-xtermjs-webview-internal:
|
||||
dependencies:
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/xterm':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
js-base64:
|
||||
specifier: ^3.7.8
|
||||
version: 3.7.8
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -3512,6 +3521,11 @@ packages:
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
'@xterm/addon-fit@0.10.0':
|
||||
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
|
||||
'@xterm/xterm@5.5.0':
|
||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||
|
||||
@@ -13028,6 +13042,10 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
'@xterm/xterm@5.5.0': {}
|
||||
|
||||
abbrev@3.0.1: {}
|
||||
|
||||
Reference in New Issue
Block a user