one ai prompt

This commit is contained in:
EthanShoeDev
2025-09-17 19:35:49 -04:00
parent 2f5568a6d5
commit b24d44155d
3 changed files with 301 additions and 93 deletions

View File

@@ -7,7 +7,6 @@ import {
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import PQueue from 'p-queue';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { Pressable, View } from 'react-native'; import { Pressable, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
@@ -19,7 +18,7 @@ export default function TabsShellDetail() {
} }
function ShellDetail() { function ShellDetail() {
const xtermWebViewRef = useRef<XtermWebViewHandle>(null); const xtermRef = useRef<XtermWebViewHandle>(null);
const { connectionId, channelId } = useLocalSearchParams<{ const { connectionId, channelId } = useLocalSearchParams<{
connectionId?: string; connectionId?: string;
channelId?: string; channelId?: string;
@@ -36,43 +35,24 @@ function ShellDetail() {
? RnRussh.getSshShell(String(connectionId), channelIdNum) ? RnRussh.getSshShell(String(connectionId), channelIdNum)
: undefined; : undefined;
function sendDataToXterm(data: ArrayBuffer) { /**
try { * SSH -> xterm (remote output)
const bytes = new Uint8Array(data.slice()); * Send bytes only; batching is handled inside XtermJsWebView.
console.log('sendDataToXterm', new TextDecoder().decode(bytes.slice())); */
xtermWebViewRef.current?.write(bytes.slice());
} catch (e) {
console.warn('Failed to decode shell data', e);
}
}
const queueRef = useRef<PQueue>(null);
useEffect(() => { useEffect(() => {
if (!queueRef.current) if (!connection) return;
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) => { const listenerId = connection.addChannelListener((data: ArrayBuffer) => {
console.log( // Forward bytes to terminal (no string conversion)
'ssh.onData', xtermRef.current?.write(new Uint8Array(data));
new TextDecoder().decode(new Uint8Array(data.slice())),
);
void xtermQueue.add(() => {
sendDataToXterm(data);
});
}); });
return () => { return () => {
connection.removeChannelListener(listenerId); connection.removeChannelListener(listenerId);
xtermQueue.pause(); // Flush any buffered writes on unmount
xtermQueue.clear(); xtermRef.current?.flush?.();
}; };
}, [connection, queueRef]); }, [connection]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -90,7 +70,7 @@ function ShellDetail() {
try { try {
await disconnectSshConnectionAndInvalidateQuery({ await disconnectSshConnectionAndInvalidateQuery({
connectionId: connection.connectionId, connectionId: connection.connectionId,
queryClient: queryClient, queryClient,
}); });
} catch (e) { } catch (e) {
console.warn('Failed to disconnect', e); console.warn('Failed to disconnect', e);
@@ -110,26 +90,33 @@ function ShellDetail() {
]} ]}
> >
<XtermJsWebView <XtermJsWebView
ref={xtermWebViewRef} ref={xtermRef}
style={{ flex: 1, height: 400 }} style={{ flex: 1 }}
// textZoom={0} // Optional: set initial theme/font
injectedJavaScript={` onLoadEnd={() => {
document.body.style.backgroundColor = '${theme.colors.background}'; // Set theme bg/fg and font settings once WebView loads; the page will
const termDiv = document.getElementById('terminal'); // still send 'initialized' after xterm is ready.
window.terminal.options.fontSize = 50; xtermRef.current?.setTheme?.(
setTimeout(() => { theme.colors.background,
window.fitAddon?.fit(); theme.colors.text,
}, 1_000); );
`} xtermRef.current?.setFont?.('Menlo, ui-monospace, monospace', 14);
}}
onMessage={(message) => { onMessage={(message) => {
if (message.type === 'initialized') { if (message.type === 'initialized') {
console.log('xterm.onMessage initialized'); // Terminal is ready; you could send a greeting or focus it
queueRef.current?.start(); xtermRef.current?.focus?.();
return; return;
} }
const data = message.data; if (message.type === 'data') {
console.log('xterm.onMessage', new TextDecoder().decode(data)); // xterm user input -> SSH
void shell?.sendData(data.slice().buffer as ArrayBuffer); // NOTE: msg.data is a fresh Uint8Array starting at offset 0
void shell?.sendData(message.data.buffer as ArrayBuffer);
return;
}
if (message.type === 'debug') {
console.log('xterm.debug', message.message);
}
}} }}
/> />
</View> </View>

View File

@@ -1,33 +1,137 @@
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
const terminal = new Terminal(); /**
const fitAddon = new FitAddon(); * Xterm setup
terminal.loadAddon(fitAddon); */
terminal.open(document.getElementById('terminal')!); const term = new Terminal({
fitAddon.fit(); allowProposedApi: true,
window.terminal = terminal; convertEol: true,
window.fitAddon = fitAddon; scrollback: 10000,
const postMessage = (arg: string) => {
window.ReactNativeWebView?.postMessage?.(arg);
};
setTimeout(() => {
postMessage('initialized');
}, 10);
terminal.onData((data) => {
const base64Data = Base64.encode(data);
postMessage(base64Data);
}); });
function terminalWriteBase64(base64Data: string) { const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const root = document.getElementById('terminal')!;
term.open(root);
fitAddon.fit();
// Expose for debugging (optional)
window.terminal = term;
window.fitAddon = fitAddon;
/**
* Post typed messages to React Native
*/
const post = (msg: unknown) =>
window.ReactNativeWebView?.postMessage?.(JSON.stringify(msg));
/**
* Encode/decode helpers
*/
const enc = new TextEncoder();
/**
* Initial handshake
*/
setTimeout(() => post({ type: 'initialized' }), 0);
/**
* User input from xterm -> RN (SSH)
* Send UTF-8 bytes only (Base64-encoded)
*/
term.onData((data /* string */) => {
const bytes = enc.encode(data);
const b64 = Base64.fromUint8Array(bytes);
post({ type: 'input', b64 });
});
/**
* Message handler for RN -> WebView control/data
* We support: write, resize, setFont, setTheme, clear, focus
*/
window.addEventListener('message', (e: MessageEvent<string>) => {
try { try {
const data = Base64.toUint8Array(base64Data); const msg = JSON.parse(e.data);
terminal.write(data); if (!msg || typeof msg.type !== 'string') return;
} catch (e) {
postMessage(`DEBUG: terminalWriteBase64 error ${e}`); switch (msg.type) {
case 'write': {
// Either a single b64 or an array of chunks
if (typeof msg.b64 === 'string') {
const bytes = Base64.toUint8Array(msg.b64);
term.write(bytes);
} else if (Array.isArray(msg.chunks)) {
for (const b64 of msg.chunks) {
const bytes = Base64.toUint8Array(b64);
term.write(bytes);
}
}
break;
}
case 'resize': {
if (typeof msg.cols === 'number' && typeof msg.rows === 'number') {
try {
term.resize(msg.cols, msg.rows);
} finally {
fitAddon.fit();
}
} else {
// If cols/rows not provided, try fit
fitAddon.fit();
}
break;
}
case 'setFont': {
const { family, size } = msg;
if (family) document.body.style.fontFamily = family;
if (typeof size === 'number')
document.body.style.fontSize = `${size}px`;
fitAddon.fit();
break;
}
case 'setTheme': {
const { background, foreground } = msg;
if (background) document.body.style.backgroundColor = background;
// xterm theme API (optional)
term.options = {
...term.options,
theme: {
...(term.options.theme ?? {}),
background,
foreground,
},
};
break;
}
case 'clear': {
term.clear();
break;
}
case 'focus': {
term.focus();
break;
}
}
} catch (err) {
post({ type: 'debug', message: `message handler error: ${String(err)}` });
} }
} });
window.terminalWriteBase64 = terminalWriteBase64;
/**
* Handle container resize
*/
new ResizeObserver(() => {
try {
fitAddon.fit();
} catch (err) {
post({ type: 'debug', message: `resize observer error: ${String(err)}` });
}
});

View File

@@ -1,12 +1,48 @@
import { useImperativeHandle, useRef, type ComponentProps } from 'react'; import React, { useEffect, useImperativeHandle, useRef } from 'react';
import { WebView } from 'react-native-webview'; import { WebView } from 'react-native-webview';
import htmlString from '../dist-internal/index.html?raw'; import htmlString from '../dist-internal/index.html?raw';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
type StrictOmit<T, K extends keyof T> = Omit<T, K>; type StrictOmit<T, K extends keyof T> = Omit<T, K>;
type InboundMessage =
| { type: 'initialized' }
| { type: 'input'; b64: string } // user typed data from xterm -> RN
| { type: 'debug'; message: string };
type OutboundMessage =
| { type: 'write'; b64: string }
| { type: 'write'; chunks: string[] }
| { type: 'resize'; cols?: number; rows?: number }
| { type: 'setFont'; family?: string; size?: number }
| { type: 'setTheme'; background?: string; foreground?: string }
| { type: 'clear' }
| { type: 'focus' };
export type XtermWebViewHandle = { export type XtermWebViewHandle = {
/**
* Push raw bytes (Uint8Array) into the terminal.
* Writes are batched (rAF or >=8KB) for performance.
*/
write: (data: Uint8Array) => void; write: (data: Uint8Array) => void;
/** Force-flush any buffered output immediately. */
flush: () => void;
/** Resize the terminal to given cols/rows (optional, fit addon also runs). */
resize: (cols?: number, rows?: number) => void;
/** Set font props inside the WebView page. */
setFont: (family?: string, size?: number) => void;
/** Set basic theme colors (background/foreground). */
setTheme: (background?: string, foreground?: string) => void;
/** Clear terminal contents. */
clear: () => void;
/** Focus the terminal input. */
focus: () => void;
}; };
export function XtermJsWebView({ export function XtermJsWebView({
@@ -14,26 +50,95 @@ export function XtermJsWebView({
onMessage, onMessage,
...props ...props
}: StrictOmit< }: StrictOmit<
ComponentProps<typeof WebView>, React.ComponentProps<typeof WebView>,
'source' | 'originWhitelist' | 'onMessage' 'source' | 'originWhitelist' | 'onMessage'
> & { > & {
ref: React.RefObject<XtermWebViewHandle | null>; ref: React.RefObject<XtermWebViewHandle | null>;
onMessage?: ( onMessage?: (
data: { type: 'data'; data: Uint8Array } | { type: 'initialized' }, msg:
| { type: 'initialized' }
| { type: 'data'; data: Uint8Array } // input from xterm (user typed)
| { type: 'debug'; message: string },
) => void; ) => void;
}) { }) {
const webViewRef = useRef<WebView>(null); const webViewRef = useRef<WebView>(null);
useImperativeHandle(ref, () => { // ---- RN -> WebView message sender via injectJavaScript + window MessageEvent
return { const send = (obj: OutboundMessage) => {
write: (data) => { const payload = JSON.stringify(obj);
const base64Data = Base64.fromUint8Array(data.slice()); const js = `window.dispatchEvent(new MessageEvent('message',{data:${JSON.stringify(
webViewRef.current?.injectJavaScript(` payload,
window?.terminalWriteBase64?.('${base64Data}'); )}})); true;`;
`); webViewRef.current?.injectJavaScript(js);
}, };
// ---- rAF + 8KB coalescer for writes
const writeBufferRef = useRef<Uint8Array | null>(null);
const rafIdRef = useRef<number | null>(null);
const THRESHOLD = 8 * 1024; // 8KB
const flush = () => {
if (!writeBufferRef.current) return;
const b64 = Base64.fromUint8Array(writeBufferRef.current);
writeBufferRef.current = null;
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
send({ type: 'write', b64 });
};
const scheduleFlush = () => {
if (rafIdRef.current != null) return;
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
flush();
});
};
const write = (data: Uint8Array) => {
if (!data || data.length === 0) return;
const chunk = data; // already a fresh Uint8Array per caller
if (!writeBufferRef.current) {
writeBufferRef.current = chunk;
} else {
// concat
const a = writeBufferRef.current;
const merged = new Uint8Array(a.length + chunk.length);
merged.set(a, 0);
merged.set(chunk, a.length);
writeBufferRef.current = merged;
}
if ((writeBufferRef.current?.length ?? 0) >= THRESHOLD) {
flush();
} else {
scheduleFlush();
}
};
useImperativeHandle(ref, () => ({
write,
flush,
resize: (cols?: number, rows?: number) =>
send({ type: 'resize', cols, rows }),
setFont: (family?: string, size?: number) =>
send({ type: 'setFont', family, size }),
setTheme: (background?: string, foreground?: string) =>
send({ type: 'setTheme', background, foreground }),
clear: () => send({ type: 'clear' }),
focus: () => send({ type: 'focus' }),
}));
// Cleanup pending rAF on unmount
useEffect(() => {
return () => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
writeBufferRef.current = null;
}; };
}); }, []);
return ( return (
<WebView <WebView
@@ -41,13 +146,25 @@ export function XtermJsWebView({
originWhitelist={['*']} originWhitelist={['*']}
source={{ html: htmlString }} source={{ html: htmlString }}
onMessage={(event) => { onMessage={(event) => {
const message = event.nativeEvent.data; try {
if (message === 'initialized') { const msg: InboundMessage = JSON.parse(event.nativeEvent.data);
onMessage?.({ type: 'initialized' }); if (msg.type === 'initialized') {
return; onMessage?.({ type: 'initialized' });
return;
}
if (msg.type === 'input') {
// Convert base64 -> bytes for the caller (SSH writer)
const bytes = Base64.toUint8Array(msg.b64);
onMessage?.({ type: 'data', data: bytes });
return;
}
if (msg.type === 'debug') {
onMessage?.({ type: 'debug', message: msg.message });
return;
}
} catch {
// ignore unknown payloads
} }
const data = Base64.toUint8Array(message.slice());
onMessage?.({ type: 'data', data });
}} }}
{...props} {...props}
/> />