new broken

This commit is contained in:
EthanShoeDev
2025-09-17 23:12:39 -04:00
parent 0fa28b2134
commit 7c448e2ec3
5 changed files with 215 additions and 71 deletions

View File

@@ -1,26 +1,56 @@
import { Ionicons } from '@expo/vector-icons';
import { RnRussh } from '@fressh/react-native-uniffi-russh';
import {
type ListenerEvent,
type TerminalChunk,
} from '@fressh/react-native-uniffi-russh';
import {
XtermJsWebView,
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 } from 'react';
import { Pressable, View } from 'react-native';
import {
Stack,
useLocalSearchParams,
useRouter,
useFocusEffect,
} from 'expo-router';
import React, { startTransition, useEffect, useRef, useState } from 'react';
import { Pressable, View, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
import { getSession } from '@/lib/ssh-registry';
import { useTheme } from '@/lib/theme';
export default function TabsShellDetail() {
const [ready, setReady] = useState(false);
useFocusEffect(
React.useCallback(() => {
startTransition(() => setReady(true)); // React 19: non-urgent
return () => setReady(false);
}, []),
);
if (!ready) return <RouteSkeleton />;
return <ShellDetail />;
}
function RouteSkeleton() {
return (
<View>
<Text>Loading</Text>
</View>
);
}
function ShellDetail() {
const xtermRef = useRef<XtermWebViewHandle>(null);
const terminalReadyRef = useRef(false);
const pendingOutputRef = useRef<Uint8Array[]>([]);
// Legacy buffer no longer used; relying on Rust ring for replay
const listenerIdRef = useRef<bigint | null>(null);
const { connectionId, channelId } = useLocalSearchParams<{
connectionId?: string;
@@ -30,36 +60,23 @@ function ShellDetail() {
const theme = useTheme();
const channelIdNum = Number(channelId);
const connection = connectionId
? RnRussh.getSshConnection(String(connectionId))
: undefined;
const shell =
const sess =
connectionId && channelId
? RnRussh.getSshShell(String(connectionId), channelIdNum)
? getSession(String(connectionId), channelIdNum)
: undefined;
const connection = sess?.connection;
const shell = sess?.shell;
// SSH -> xterm (remote output). Buffer until xterm is initialized.
// SSH -> xterm: on initialized, replay ring head then attach live listener
useEffect(() => {
if (!connection) return;
const xterm = xtermRef.current;
const listenerId = connection.addChannelListener((ab: ArrayBuffer) => {
const bytes = new Uint8Array(ab);
if (!terminalReadyRef.current) {
pendingOutputRef.current.push(bytes);
console.log('SSH->buffer', { len: bytes.length });
return;
}
console.log('SSH->xterm', { len: bytes.length });
xterm?.write(bytes);
});
return () => {
connection.removeChannelListener(listenerId);
if (shell && listenerIdRef.current != null)
shell.removeListener(listenerIdRef.current);
listenerIdRef.current = null;
xterm?.flush?.();
};
}, [connection]);
}, [shell]);
const queryClient = useQueryClient();
@@ -133,21 +150,33 @@ function ShellDetail() {
if (m.type === 'initialized') {
terminalReadyRef.current = true;
// Flush buffered banner/welcome lines
if (pendingOutputRef.current.length) {
const total = pendingOutputRef.current.reduce(
(n, a) => n + a.length,
0,
);
console.log('Flushing buffered output', {
chunks: pendingOutputRef.current.length,
bytes: total,
});
for (const chunk of pendingOutputRef.current) {
xtermRef.current?.write(chunk);
}
pendingOutputRef.current = [];
xtermRef.current?.flush?.();
// Replay from head, then attach live listener
if (shell) {
void (async () => {
const res = await 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);
xtermRef.current?.writeMany?.(chunks);
xtermRef.current?.flush?.();
}
const id = shell.addListener(
(ev: ListenerEvent) => {
if ('kind' in ev && ev.kind === 'dropped') {
console.log('listener.dropped', ev);
return;
}
const chunk = ev as TerminalChunk;
xtermRef.current?.write(chunk.bytes);
},
{ cursor: { mode: 'live' } },
);
listenerIdRef.current = id;
})();
}
// Focus to pop the keyboard (iOS needs the prop we set)

View File

@@ -1,8 +1,5 @@
import { Ionicons } from '@expo/vector-icons';
import {
type RnRussh,
type SshConnection,
} from '@fressh/react-native-uniffi-russh';
import { type SshConnection } from '@fressh/react-native-uniffi-russh';
import { FlashList } from '@shopify/flash-list';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
@@ -24,6 +21,7 @@ import {
listSshShellsQueryOptions,
type ShellWithConnection,
} from '@/lib/query-fns';
import { type listConnectionsWithShells as registryList } from '@/lib/ssh-registry';
import { useTheme } from '@/lib/theme';
export default function TabsShellList() {
@@ -80,11 +78,9 @@ type ActionTarget =
connection: SshConnection;
};
function LoadedState({
connections,
}: {
connections: ReturnType<typeof RnRussh.listSshConnectionsWithShells>;
}) {
type ConnectionsList = ReturnType<typeof registryList>;
function LoadedState({ connections }: { connections: ConnectionsList }) {
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
null,
);
@@ -137,9 +133,7 @@ function FlatView({
connectionsWithShells,
setActionTarget,
}: {
connectionsWithShells: ReturnType<
typeof RnRussh.listSshConnectionsWithShells
>;
connectionsWithShells: ConnectionsList;
setActionTarget: (target: ActionTarget) => void;
}) {
const flatShells = React.useMemo(() => {
@@ -149,7 +143,7 @@ function FlatView({
}, []);
}, [connectionsWithShells]);
return (
<FlashList
<FlashList<ShellWithConnection>
data={flatShells}
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
renderItem={({ item }) => (
@@ -176,15 +170,13 @@ function GroupedView({
connectionsWithShells,
setActionTarget,
}: {
connectionsWithShells: ReturnType<
typeof RnRussh.listSshConnectionsWithShells
>;
connectionsWithShells: ConnectionsList;
setActionTarget: (target: ActionTarget) => void;
}) {
const theme = useTheme();
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
return (
<FlashList
<FlashList<ConnectionsList[number]>
data={connectionsWithShells}
// estimatedItemSize={80}
keyExtractor={(item) => item.connectionId}

View File

@@ -1,8 +1,4 @@
import {
RnRussh,
type SshConnection,
type SshShellSession,
} from '@fressh/react-native-uniffi-russh';
import { RnRussh } from '@fressh/react-native-uniffi-russh';
import {
queryOptions,
useMutation,
@@ -11,6 +7,11 @@ import {
} from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { secretsManager, type InputConnectionDetails } from './secrets-manager';
import {
listConnectionsWithShells as registryList,
registerSession,
type ShellWithConnection,
} from './ssh-registry';
import { AbortSignalTimeout } from './utils';
export const useSshConnMutation = () => {
@@ -57,6 +58,9 @@ export const useSshConnMutation = () => {
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
console.log('Connected to SSH server', connectionId, channelId);
// Track in registry for app use
registerSession(sshConnection, shellInterface);
await queryClient.invalidateQueries({
queryKey: listSshShellsQueryOptions.queryKey,
});
@@ -77,19 +81,17 @@ export const useSshConnMutation = () => {
export const listSshShellsQueryOptions = queryOptions({
queryKey: ['ssh-shells'],
queryFn: () => RnRussh.listSshConnectionsWithShells(),
queryFn: () => registryList(),
});
export type ShellWithConnection = SshShellSession & {
connection: SshConnection;
};
export type { ShellWithConnection };
export const closeSshShellAndInvalidateQuery = async (params: {
channelId: number;
connectionId: string;
queryClient: QueryClient;
}) => {
const currentActiveShells = RnRussh.listSshConnectionsWithShells();
const currentActiveShells = registryList();
const connection = currentActiveShells.find(
(c) => c.connectionId === params.connectionId,
);
@@ -97,7 +99,6 @@ export const closeSshShellAndInvalidateQuery = async (params: {
const shell = connection.shells.find((s) => s.channelId === params.channelId);
if (!shell) throw new Error('Shell not found');
await shell.close();
if (connection.shells.length <= 1) await connection.disconnect();
await params.queryClient.invalidateQueries({
queryKey: listSshShellsQueryOptions.queryKey,
});
@@ -107,7 +108,10 @@ export const disconnectSshConnectionAndInvalidateQuery = async (params: {
connectionId: string;
queryClient: QueryClient;
}) => {
const connection = RnRussh.getSshConnection(params.connectionId);
const currentActiveShells = registryList();
const connection = currentActiveShells.find(
(c) => c.connectionId === params.connectionId,
);
if (!connection) throw new Error('Connection not found');
await connection.disconnect();
await params.queryClient.invalidateQueries({

View File

@@ -0,0 +1,108 @@
import {
RnRussh,
type SshConnection,
type SshShell,
} from '@fressh/react-native-uniffi-russh';
// Simple in-memory registry owned by JS to track active handles.
// Keyed by `${connectionId}:${channelId}`.
export type SessionKey = string;
export type StoredSession = {
connection: SshConnection;
shell: SshShell;
};
const sessions = new Map<SessionKey, StoredSession>();
export function makeSessionKey(
connectionId: string,
channelId: number,
): SessionKey {
return `${connectionId}:${channelId}`;
}
export function registerSession(
connection: SshConnection,
shell: SshShell,
): SessionKey {
const key = makeSessionKey(connection.connectionId, shell.channelId);
sessions.set(key, { connection, shell });
return key;
}
export function getSession(
connectionId: string,
channelId: number,
): StoredSession | undefined {
return sessions.get(makeSessionKey(connectionId, channelId));
}
export function removeSession(connectionId: string, channelId: number): void {
sessions.delete(makeSessionKey(connectionId, channelId));
}
export function listSessions(): StoredSession[] {
return Array.from(sessions.values());
}
// Legacy list view expected shape
export type ShellWithConnection = StoredSession['shell'] & {
connection: SshConnection;
};
export function listConnectionsWithShells(): (SshConnection & {
shells: StoredSession['shell'][];
})[] {
// Group shells by connection
const byConn = new Map<string, { conn: SshConnection; shells: SshShell[] }>();
for (const { connection, shell } of sessions.values()) {
const g = byConn.get(connection.connectionId) ?? {
conn: connection,
shells: [],
};
g.shells.push(shell);
byConn.set(connection.connectionId, g);
}
return Array.from(byConn.values()).map(({ conn, shells }) => ({
...conn,
shells,
}));
}
// Convenience helpers for flows
export async function connectAndStart(
details: Parameters<typeof RnRussh.connect>[0],
) {
const conn = await RnRussh.connect(details);
const shell = await conn.startShell({ pty: 'Xterm' });
registerSession(conn, shell);
return { conn, shell };
}
export async function closeShell(connectionId: string, channelId: number) {
const sess = getSession(connectionId, channelId);
if (!sess) return;
await sess.shell.close();
removeSession(connectionId, channelId);
}
export async function disconnectConnection(connectionId: string) {
const remaining = Array.from(sessions.entries()).filter(
([, v]) => v.connection.connectionId === connectionId,
);
for (const [key, sess] of remaining) {
try {
await sess.shell.close();
} catch {}
sessions.delete(key);
}
// Find one connection handle for this id to disconnect
const conn = remaining[0]?.[1].connection;
if (conn) {
try {
await conn.disconnect();
} catch {}
}
}

View File

@@ -35,6 +35,8 @@ export type XtermInbound =
export type XtermWebViewHandle = {
write: (data: Uint8Array) => void; // bytes in (batched)
// Efficiently write many chunks in one postMessage (for initial replay)
writeMany: (chunks: Uint8Array[]) => void;
flush: () => void; // force-flush outgoing writes
resize: (cols?: number, rows?: number) => void;
setFont: (family?: string, size?: number) => void;
@@ -127,8 +129,17 @@ export function XtermJsWebView({
else schedule();
};
const writeMany = (chunks: Uint8Array[]) => {
if (!chunks || chunks.length === 0) return;
// Ensure any pending small buffered write is flushed before bulk write
flush();
const b64s = chunks.map((c) => Base64.fromUint8Array(c));
send({ type: 'write', chunks: b64s });
};
useImperativeHandle(ref, () => ({
write,
writeMany,
flush,
resize: (cols?: number, rows?: number) =>
send({ type: 'resize', cols, rows }),