mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
commit
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ScrollView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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<ShellWithConnection[]>(
|
||||
(acc, curr) => {
|
||||
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr })));
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{shellsFirstList.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<FlashList
|
||||
data={shellsFirstList}
|
||||
renderItem={({ item }) => <ShellCard shell={item} />}
|
||||
// maintainVisibleContentPosition={{ autoscrollToBottomThreshold: 0.2 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>No active shells. Connect from Host tab.</Text>
|
||||
@@ -10,6 +43,24 @@ export default function ShellList() {
|
||||
);
|
||||
}
|
||||
|
||||
function ShellCard({ shell }: { shell: ShellWithConnection }) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>{shell.connectionId}</Text>
|
||||
<Text style={styles.text}>{shell.createdAtMs}</Text>
|
||||
<Text style={styles.text}>{shell.pty}</Text>
|
||||
<Text style={styles.text}>{shell.connection.connectionDetails.host}</Text>
|
||||
<Text style={styles.text}>{shell.connection.connectionDetails.port}</Text>
|
||||
<Text style={styles.text}>
|
||||
{shell.connection.connectionDetails.username}
|
||||
</Text>
|
||||
<Text style={styles.text}>
|
||||
{shell.connection.connectionDetails.security.type}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
text: { color: '#E5E7EB', marginBottom: 8 },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import {
|
||||
createFormHook,
|
||||
createFormHookContexts,
|
||||
@@ -98,31 +97,6 @@ export function SwitchField(
|
||||
);
|
||||
}
|
||||
|
||||
export function PickerField<T>(
|
||||
props: React.ComponentProps<typeof Picker<T>> & {
|
||||
label?: string;
|
||||
},
|
||||
) {
|
||||
const { label, style, ...rest } = props;
|
||||
const field = useFieldContext<T>();
|
||||
return (
|
||||
<View style={styles.inputGroup}>
|
||||
{label ? <Text style={styles.label}>{label}</Text> : null}
|
||||
<View style={[styles.input, styles.pickerContainer]}>
|
||||
<Picker<T>
|
||||
style={styles.picker}
|
||||
selectedValue={field.state.value}
|
||||
onValueChange={(itemValue) => field.handleChange(itemValue)}
|
||||
{...rest}
|
||||
>
|
||||
{props.children}
|
||||
</Picker>
|
||||
</View>
|
||||
<FieldInfo />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubmitButton(
|
||||
props: {
|
||||
onPress?: () => void;
|
||||
@@ -162,7 +136,6 @@ export const { useAppForm, withForm, withFieldGroup } = createFormHook({
|
||||
fieldComponents: {
|
||||
TextField,
|
||||
NumberField,
|
||||
PickerField,
|
||||
SwitchField,
|
||||
},
|
||||
formComponents: {
|
||||
|
||||
@@ -483,6 +483,18 @@ pub fn list_ssh_connections() -> Vec<SshConnectionInfo> {
|
||||
out
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn list_ssh_shells() -> Vec<ShellSessionInfo> {
|
||||
// Collect shells outside the lock to avoid holding a MutexGuard across await
|
||||
let shells: Vec<Arc<ShellSession>> = 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<Arc<SSHConnection>, SshError> {
|
||||
if let Ok(map) = CONNECTIONS.lock() { if let Some(conn) = map.get(&id) { return Ok(conn.clone()); } }
|
||||
|
||||
@@ -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<SshConnection>;
|
||||
|
||||
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<string>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user