diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ef5383c..85b861c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -28,11 +28,11 @@ "@expo/vector-icons": "^15.0.2", "@fressh/assets": "workspace:*", "@fressh/react-native-uniffi-russh": "workspace:*", - "@react-native-picker/picker": "2.11.1", "@react-native-segmented-control/segmented-control": "2.5.7", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.4", "@react-navigation/native": "^7.1.8", + "@shopify/flash-list": "2.0.2", "@tanstack/react-form": "^1.20.0", "@tanstack/react-query": "^5.87.4", "expo": "54.0.7", diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 01377a5..c1d29d5 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ -import React from 'react'; import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'; +import React from 'react'; export default function TabsLayout() { return ( diff --git a/apps/mobile/src/app/host.tsx b/apps/mobile/src/app/host.tsx index e8220dd..19adb10 100644 --- a/apps/mobile/src/app/host.tsx +++ b/apps/mobile/src/app/host.tsx @@ -16,6 +16,7 @@ import { SafeAreaView, useSafeAreaInsets, } from 'react-native-safe-area-context'; +import { KeyList } from '@/components/key-manager/KeyList'; import { AbortSignalTimeout } from '@/lib/utils'; import { useAppForm, useFieldContext } from '../components/form-components'; import { @@ -25,8 +26,6 @@ import { } from '../lib/secrets-manager'; // import { sshConnectionManager } from '../lib/ssh-connection-manager'; import { useTheme } from '../theme'; -import { useFocusEffect } from '@react-navigation/native'; -import { KeyList } from '@/components/key-manager/KeyList'; const defaultValues: ConnectionDetails = { host: 'test.rebex.net', @@ -260,11 +259,15 @@ function KeyIdPickerField() { }, [listPrivateKeysQuery.data]); const keys = listPrivateKeysQuery.data ?? []; + const fieldValue = field.state.value; + const defaultPickId = defaultPick?.id; + const fieldHandleChange = field.handleChange; + React.useEffect(() => { - if (!field.state.value && defaultPick?.id) { - field.handleChange(defaultPick.id); + if (!fieldValue && defaultPickId) { + fieldHandleChange(defaultPickId); } - }, [field.state.value, defaultPick?.id]); + }, [fieldValue, defaultPickId, fieldHandleChange]); const computedSelectedId = field.state.value ?? defaultPick?.id; const selected = keys.find((k) => k.id === computedSelectedId); diff --git a/apps/mobile/src/app/shell/[connectionId]/[channelId].tsx b/apps/mobile/src/app/shell/[connectionId]/[channelId].tsx index 6c37837..5512b9c 100644 --- a/apps/mobile/src/app/shell/[connectionId]/[channelId].tsx +++ b/apps/mobile/src/app/shell/[connectionId]/[channelId].tsx @@ -68,13 +68,6 @@ export default function ShellDetail() { }; }, [connection]); - // Cleanup when leaving screen - useEffect(() => { - return () => { - if (connection) void connection.disconnect().catch(() => {}); - }; - }, [connection, shell]); - const scrollViewRef = useRef(null); useEffect(() => { diff --git a/apps/mobile/src/app/shell/index.tsx b/apps/mobile/src/app/shell/index.tsx index 491f47d..accd662 100644 --- a/apps/mobile/src/app/shell/index.tsx +++ b/apps/mobile/src/app/shell/index.tsx @@ -1,7 +1,40 @@ +import { + RnRussh, + type SshConnection, + type SshShellSession, +} from '@fressh/react-native-uniffi-russh'; +import { FlashList } from '@shopify/flash-list'; import { Link } from 'expo-router'; import { StyleSheet, Text, View } from 'react-native'; +type ShellWithConnection = SshShellSession & { connection: SshConnection }; + export default function ShellList() { + const connectionsWithShells = RnRussh.listSshConnectionsWithShells(); + const shellsFirstList = connectionsWithShells.reduce( + (acc, curr) => { + acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr }))); + return acc; + }, + [], + ); + + return ( + + {shellsFirstList.length === 0 ? ( + + ) : ( + } + // maintainVisibleContentPosition={{ autoscrollToBottomThreshold: 0.2 }} + /> + )} + + ); +} + +function EmptyState() { return ( No active shells. Connect from Host tab. @@ -10,6 +43,24 @@ export default function ShellList() { ); } +function ShellCard({ shell }: { shell: ShellWithConnection }) { + return ( + + {shell.connectionId} + {shell.createdAtMs} + {shell.pty} + {shell.connection.connectionDetails.host} + {shell.connection.connectionDetails.port} + + {shell.connection.connectionDetails.username} + + + {shell.connection.connectionDetails.security.type} + + + ); +} + const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center' }, text: { color: '#E5E7EB', marginBottom: 8 }, diff --git a/apps/mobile/src/components/form-components.tsx b/apps/mobile/src/components/form-components.tsx index 4e30511..b063527 100644 --- a/apps/mobile/src/components/form-components.tsx +++ b/apps/mobile/src/components/form-components.tsx @@ -1,4 +1,3 @@ -import { Picker } from '@react-native-picker/picker'; import { createFormHook, createFormHookContexts, @@ -98,31 +97,6 @@ export function SwitchField( ); } -export function PickerField( - props: React.ComponentProps> & { - label?: string; - }, -) { - const { label, style, ...rest } = props; - const field = useFieldContext(); - return ( - - {label ? {label} : null} - - - style={styles.picker} - selectedValue={field.state.value} - onValueChange={(itemValue) => field.handleChange(itemValue)} - {...rest} - > - {props.children} - - - - - ); -} - export function SubmitButton( props: { onPress?: () => void; @@ -162,7 +136,6 @@ export const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { TextField, NumberField, - PickerField, SwitchField, }, formComponents: { diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs index 00f1c96..0653ae9 100644 --- a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs @@ -483,6 +483,18 @@ pub fn list_ssh_connections() -> Vec { out } +#[uniffi::export] +pub fn list_ssh_shells() -> Vec { + // Collect shells outside the lock to avoid holding a MutexGuard across await + let shells: Vec> = SHELLS + .lock() + .map(|map| map.values().cloned().collect()) + .unwrap_or_default(); + let mut out = Vec::with_capacity(shells.len()); + for shell in shells { out.push(shell.info()); } + out +} + #[uniffi::export] pub fn get_ssh_connection(id: String) -> Result, SshError> { if let Ok(map) = CONNECTIONS.lock() { if let Some(conn) = map.get(&id) { return Ok(conn.clone()); } } diff --git a/packages/react-native-uniffi-russh/src/api.ts b/packages/react-native-uniffi-russh/src/api.ts index dae4469..ab6923c 100644 --- a/packages/react-native-uniffi-russh/src/api.ts +++ b/packages/react-native-uniffi-russh/src/api.ts @@ -43,6 +43,7 @@ export type SshShellSession = { readonly channelId: number; readonly createdAtMs: number; readonly pty: GeneratedRussh.PtyType; + readonly connectionId: string; sendData: ( data: ArrayBuffer, options?: { signal: AbortSignal } @@ -55,8 +56,10 @@ type RusshApi = { connect: (options: ConnectOptions) => Promise; getSshConnection: (id: string) => SshConnection | undefined; - listSshConnections: () => SshConnection[]; getSshShell: (connectionId: string, channelId: number) => SshShellSession | undefined; + listSshConnections: () => SshConnection[]; + listSshShells: () => SshShellSession[]; + listSshConnectionsWithShells: () => (SshConnection & { shells: SshShellSession[] })[]; generateKeyPair: (type: PrivateKeyType) => Promise; @@ -148,6 +151,7 @@ function wrapShellSession(shell: GeneratedRussh.ShellSessionInterface): SshShell channelId: info.channelId, createdAtMs: info.createdAtMs, pty: info.pty, + connectionId: info.connectionId, sendData: shell.sendData.bind(shell), close: shell.close.bind(shell) }; @@ -216,6 +220,33 @@ function listSshConnections(): SshConnection[] { return out; } +function listSshShells(): SshShellSession[] { + const infos = GeneratedRussh.listSshShells(); + const out: SshShellSession[] = []; + for (const info of infos) { + try { + const shell = GeneratedRussh.getSshShell(info.connectionId, info.channelId); + out.push(wrapShellSession(shell)); + } catch { + // ignore entries that no longer exist between snapshot and lookup + } + } + return out; +} + +/** + * TODO: This feels a bit hacky. It is probably more effecient to do this join in rust and send + * the joined result to the app. + */ +function listSshConnectionsWithShells(): (SshConnection & { shells: SshShellSession[] })[] { + const connections = listSshConnections(); + const shells = listSshShells(); + return connections.map(connection => ({ + ...connection, + shells: shells.filter(shell => shell.connectionId === connection.connectionId), + })); +} + async function generateKeyPair(type: PrivateKeyType) { return GeneratedRussh.generateKeyPair(privateKeyTypeLiteralToEnum[type]); @@ -229,5 +260,7 @@ export const RnRussh = { generateKeyPair, getSshConnection, listSshConnections, + listSshShells, + listSshConnectionsWithShells, getSshShell, } satisfies RusshApi; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20dab69..4f9ea5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,9 +49,6 @@ importers: '@fressh/react-native-uniffi-russh': specifier: workspace:* version: link:../../packages/react-native-uniffi-russh - '@react-native-picker/picker': - specifier: 2.11.1 - version: 2.11.1(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))(react@19.1.0) '@react-native-segmented-control/segmented-control': specifier: 2.5.7 version: 2.5.7(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))(react@19.1.0) @@ -64,6 +61,9 @@ importers: '@react-navigation/native': specifier: ^7.1.8 version: 7.1.17(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))(react@19.1.0) + '@shopify/flash-list': + specifier: 2.0.2 + version: 2.0.2(@babel/runtime@7.28.3)(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))(react@19.1.0) '@tanstack/react-form': specifier: ^1.20.0 version: 1.20.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -238,7 +238,7 @@ importers: dependencies: uniffi-bindgen-react-native: specifier: github:EthanShoeDev/uniffi-bindgen-react-native#build-ts - version: https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/5933e251a5464a8209e78fd679b72293ef4c68bb(patch_hash=527b712c8fb029b29d9ac7caa72e593fa37a6dcebb63e15a56e21e75ffcb88ec) + version: https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/54dd681081a4117ee417f78607a942544636b145(patch_hash=527b712c8fb029b29d9ac7caa72e593fa37a6dcebb63e15a56e21e75ffcb88ec) devDependencies: '@eslint/compat': specifier: ^1.3.2 @@ -2326,12 +2326,6 @@ packages: engines: {node: '>=20.19.4'} hasBin: true - '@react-native-picker/picker@2.11.1': - resolution: {integrity: sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==} - peerDependencies: - react: '*' - react-native: '*' - '@react-native-segmented-control/segmented-control@2.5.7': resolution: {integrity: sha512-l84YeVX8xAU3lvOJSvV4nK/NbGhIm2gBfveYolwaoCbRp+/SLXtc6mYrQmM9ScXNwU14mnzjQTpTHWl5YPnkzQ==} peerDependencies: @@ -2618,6 +2612,13 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shopify/flash-list@2.0.2': + resolution: {integrity: sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==} + peerDependencies: + '@babel/runtime': '*' + react: '*' + react-native: '*' + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -8180,8 +8181,8 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} - uniffi-bindgen-react-native@https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/5933e251a5464a8209e78fd679b72293ef4c68bb: - resolution: {tarball: https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/5933e251a5464a8209e78fd679b72293ef4c68bb} + uniffi-bindgen-react-native@https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/54dd681081a4117ee417f78607a942544636b145: + resolution: {tarball: https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/54dd681081a4117ee417f78607a942544636b145} version: 0.29.3-1 hasBin: true @@ -11282,11 +11283,6 @@ snapshots: - typescript - utf-8-validate - '@react-native-picker/picker@2.11.1(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))(react@19.1.0)': - dependencies: - 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) - '@react-native-segmented-control/segmented-control@2.5.7(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))(react@19.1.0)': dependencies: react: 19.1.0 @@ -11683,6 +11679,13 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@shopify/flash-list@2.0.2(@babel/runtime@7.28.3)(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))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.28.3 + 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) + tslib: 2.8.1 + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -18502,7 +18505,7 @@ snapshots: unicorn-magic@0.3.0: {} - uniffi-bindgen-react-native@https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/5933e251a5464a8209e78fd679b72293ef4c68bb(patch_hash=527b712c8fb029b29d9ac7caa72e593fa37a6dcebb63e15a56e21e75ffcb88ec): {} + uniffi-bindgen-react-native@https://codeload.github.com/EthanShoeDev/uniffi-bindgen-react-native/tar.gz/54dd681081a4117ee417f78607a942544636b145(patch_hash=527b712c8fb029b29d9ac7caa72e593fa37a6dcebb63e15a56e21e75ffcb88ec): {} unified@11.0.5: dependencies: