organize routes

This commit is contained in:
EthanShoeDev
2025-09-15 16:05:39 -04:00
parent b078d97af1
commit d2695577ca
11 changed files with 929 additions and 890 deletions

View File

@@ -1,6 +1,519 @@
import SegmentedControl from '@react-native-segmented-control/segmented-control';
import { useStore } from '@tanstack/react-form';
import { useQuery } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import Host from '../host'; import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
Modal,
} from 'react-native';
import {
SafeAreaView,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
import { useAppForm, useFieldContext } from '@/components/form-components';
import { KeyList } from '@/components/key-manager/KeyList';
import { useSshConnMutation } from '@/lib/query-fns';
import {
type ConnectionDetails,
connectionDetailsSchema,
secretsManager,
} from '@/lib/secrets-manager';
import { useTheme } from '@/theme';
export default function TabsIndex() { export default function TabsIndex() {
return <Host />; return <Host />;
} }
const defaultValues: ConnectionDetails = {
host: 'test.rebex.net',
port: 22,
username: 'demo',
security: {
type: 'password',
password: 'password',
},
};
function Host() {
const theme = useTheme();
const insets = useSafeAreaInsets();
const sshConnMutation = useSshConnMutation();
const connectionForm = useAppForm({
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
defaultValues,
validators: {
onChange: connectionDetailsSchema,
onSubmitAsync: async ({ value }) => sshConnMutation.mutateAsync(value),
},
});
const securityType = useStore(
connectionForm.store,
(state) => state.values.security.type,
);
const isSubmitting = useStore(
connectionForm.store,
(state) => state.isSubmitting,
);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: Math.max(32, insets.bottom + 24) },
]}
keyboardShouldPersistTaps="handled"
style={{ backgroundColor: theme.colors.background }}
>
<View
style={[
styles.container,
{ backgroundColor: theme.colors.background },
]}
>
<View style={styles.header}>
<Text style={styles.appName}>fressh</Text>
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
</View>
<View style={styles.card}>
<Text style={styles.title}>Connect to SSH Server</Text>
<Text style={styles.subtitle}>Enter your server credentials</Text>
<connectionForm.AppForm>
<connectionForm.AppField name="host">
{(field) => (
<field.TextField
label="Host"
testID="host"
placeholder="example.com or 192.168.0.10"
autoCapitalize="none"
autoCorrect={false}
/>
)}
</connectionForm.AppField>
<connectionForm.AppField name="port">
{(field) => (
<field.NumberField
label="Port"
placeholder="22"
testID="port"
/>
)}
</connectionForm.AppField>
<connectionForm.AppField name="username">
{(field) => (
<field.TextField
label="Username"
testID="username"
placeholder="root"
autoCapitalize="none"
autoCorrect={false}
/>
)}
</connectionForm.AppField>
<connectionForm.AppField name="security.type">
{(field) => (
<View style={styles.inputGroup}>
<SegmentedControl
values={['Password', 'Private Key']}
selectedIndex={field.state.value === 'password' ? 0 : 1}
onChange={(event) => {
field.handleChange(
event.nativeEvent.selectedSegmentIndex === 0
? 'password'
: 'key',
);
}}
/>
</View>
)}
</connectionForm.AppField>
{securityType === 'password' ? (
<connectionForm.AppField name="security.password">
{(field) => (
<field.TextField
label="Password"
testID="password"
placeholder="••••••••"
secureTextEntry
/>
)}
</connectionForm.AppField>
) : (
<connectionForm.AppField name="security.keyId">
{() => <KeyIdPickerField />}
</connectionForm.AppField>
)}
<View style={styles.actions}>
<connectionForm.SubmitButton
title="Connect"
testID="connect"
onPress={() => {
if (isSubmitting) return;
void connectionForm.handleSubmit();
}}
/>
</View>
</connectionForm.AppForm>
</View>
<PreviousConnectionsSection
onSelect={(connection) => {
connectionForm.setFieldValue('host', connection.host);
connectionForm.setFieldValue('port', connection.port);
connectionForm.setFieldValue('username', connection.username);
connectionForm.setFieldValue(
'security.type',
connection.security.type,
);
if (connection.security.type === 'password') {
connectionForm.setFieldValue(
'security.password',
connection.security.password,
);
} else {
connectionForm.setFieldValue(
'security.keyId',
connection.security.keyId,
);
}
}}
/>
</View>
</ScrollView>
</SafeAreaView>
);
}
function KeyIdPickerField() {
const field = useFieldContext<string>();
const [open, setOpen] = React.useState(false);
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
const defaultPick = React.useMemo(() => {
const keys = listPrivateKeysQuery.data ?? [];
const def = keys.find((k) => k.metadata?.isDefault);
return def ?? keys[0];
}, [listPrivateKeysQuery.data]);
const keys = listPrivateKeysQuery.data ?? [];
const fieldValue = field.state.value;
const defaultPickId = defaultPick?.id;
const fieldHandleChange = field.handleChange;
React.useEffect(() => {
if (!fieldValue && defaultPickId) {
fieldHandleChange(defaultPickId);
}
}, [fieldValue, defaultPickId, fieldHandleChange]);
const computedSelectedId = field.state.value ?? defaultPick?.id;
const selected = keys.find((k) => k.id === computedSelectedId);
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None';
return (
<>
<View style={styles.inputGroup}>
<Text style={styles.label}>Private Key</Text>
<Pressable
style={[styles.input, { justifyContent: 'center' }]}
onPress={() => {
void listPrivateKeysQuery.refetch();
setOpen(true);
}}
>
<Text style={{ color: '#E5E7EB' }}>{display}</Text>
</Pressable>
{!selected && (
<Text style={styles.mutedText}>
Open Key Manager to add/select a key
</Text>
)}
</View>
<Modal
visible={open}
transparent
animationType="slide"
onRequestClose={() => setOpen(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.title}>Select Key</Text>
<Pressable
style={styles.modalCloseButton}
onPress={() => setOpen(false)}
>
<Text style={styles.modalCloseText}>Close</Text>
</Pressable>
</View>
<KeyList
mode="select"
onSelect={async (id) => {
field.handleChange(id);
setOpen(false);
}}
/>
</View>
</View>
</Modal>
</>
);
}
function PreviousConnectionsSection(props: {
onSelect: (connection: ConnectionDetails) => void;
}) {
const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
return (
<View style={styles.listSection}>
<Text style={styles.listTitle}>Previous Connections</Text>
{listConnectionsQuery.isLoading ? (
<Text style={styles.mutedText}>Loading connections...</Text>
) : listConnectionsQuery.isError ? (
<Text style={styles.errorText}>Error loading connections</Text>
) : listConnectionsQuery.data?.length ? (
<View style={styles.listContainer}>
{listConnectionsQuery.data?.map((conn) => (
<ConnectionRow
key={conn.id}
id={conn.id}
onSelect={props.onSelect}
/>
))}
</View>
) : (
<Text style={styles.mutedText}>No saved connections yet</Text>
)}
</View>
);
}
function ConnectionRow(props: {
id: string;
onSelect: (connection: ConnectionDetails) => void;
}) {
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
const details = detailsQuery.data?.value;
return (
<Pressable
style={styles.row}
onPress={() => {
if (details) props.onSelect(details);
}}
disabled={!details}
>
<View style={styles.rowTextContainer}>
<Text style={styles.rowTitle}>
{details ? `${details.username}@${details.host}` : 'Loading...'}
</Text>
<Text style={styles.rowSubtitle}>
{details ? `Port ${details.port}${details.security.type}` : ''}
</Text>
</View>
<Text style={styles.rowChevron}></Text>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
backgroundColor: '#0B1324',
justifyContent: 'center',
},
scrollContent: {
paddingBottom: 32,
},
header: {
marginBottom: 16,
alignItems: 'center',
},
appName: {
fontSize: 28,
fontWeight: '800',
color: '#E5E7EB',
letterSpacing: 1,
},
appTagline: {
marginTop: 4,
fontSize: 13,
color: '#9AA0A6',
},
card: {
backgroundColor: '#111B34',
borderRadius: 20,
padding: 24,
marginHorizontal: 4,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 16,
shadowOffset: { width: 0, height: 4 },
elevation: 8,
borderWidth: 1,
borderColor: '#1E293B',
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#E5E7EB',
marginBottom: 6,
letterSpacing: 0.5,
},
subtitle: {
fontSize: 15,
color: '#9AA0A6',
marginBottom: 24,
lineHeight: 20,
},
inputGroup: {
marginBottom: 12,
},
label: {
marginBottom: 6,
fontSize: 14,
color: '#C6CBD3',
fontWeight: '600',
},
input: {
borderWidth: 1,
borderColor: '#2A3655',
backgroundColor: '#0E172B',
color: '#E5E7EB',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
},
errorText: {
marginTop: 6,
color: '#FCA5A5',
fontSize: 12,
},
actions: {
marginTop: 20,
},
mutedText: {
color: '#9AA0A6',
fontSize: 14,
},
submitButton: {
backgroundColor: '#2563EB',
borderRadius: 12,
paddingVertical: 16,
alignItems: 'center',
shadowColor: '#2563EB',
shadowOpacity: 0.3,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 4,
},
submitButtonText: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 16,
letterSpacing: 0.5,
},
buttonDisabled: {
backgroundColor: '#3B82F6',
opacity: 0.6,
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#2A3655',
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
marginTop: 12,
},
secondaryButtonText: {
color: '#C6CBD3',
fontWeight: '600',
fontSize: 14,
letterSpacing: 0.3,
},
listSection: {
marginTop: 20,
},
listTitle: {
fontSize: 16,
fontWeight: '700',
color: '#E5E7EB',
marginBottom: 8,
},
listContainer: {
// Intentionally empty for RN compatibility
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#0E172B',
borderWidth: 1,
borderColor: '#2A3655',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
marginBottom: 8,
},
rowTextContainer: {
flex: 1,
marginRight: 12,
},
rowTitle: {
color: '#E5E7EB',
fontSize: 15,
fontWeight: '600',
},
rowSubtitle: {
color: '#9AA0A6',
marginTop: 2,
fontSize: 12,
},
rowChevron: {
color: '#9AA0A6',
fontSize: 22,
paddingHorizontal: 4,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'flex-end',
},
modalSheet: {
backgroundColor: '#0B1324',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 16,
borderColor: '#1E293B',
borderWidth: 1,
maxHeight: '85%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
modalCloseButton: {
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
borderWidth: 1,
borderColor: '#2A3655',
},
modalCloseText: {
color: '#C6CBD3',
fontWeight: '600',
},
});

View File

@@ -1 +1,25 @@
export { default } from '../../settings'; import { Link } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function Tab() {
return (
<View style={styles.container}>
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
<Link
href="/(tabs)/settings/key-manager"
style={{ color: '#60A5FA', marginBottom: 8 }}
>
Manage Keys
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#0B1324',
},
});

View File

@@ -1,6 +1,217 @@
import React from 'react'; import { RnRussh } from '@fressh/react-native-uniffi-russh';
import ShellDetail from '../../../shell/[connectionId]/[channelId]'; import {
Link,
Stack,
useLocalSearchParams,
useNavigation,
useRouter,
} from 'expo-router';
import React, { useEffect, useRef, useState } from 'react';
import {
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTheme } from '@/theme';
export default function TabsShellDetail() { export default function TabsShellDetail() {
return <ShellDetail />; return <ShellDetail />;
} }
function ShellDetail() {
const { connectionId, channelId } = useLocalSearchParams<{
connectionId: string;
channelId: string;
}>();
const router = useRouter();
const theme = useTheme();
const channelIdNum = Number(channelId);
const connection = RnRussh.getSshConnection(connectionId);
const shell = RnRussh.getSshShell(connectionId, channelIdNum);
const [shellData, setShellData] = useState('');
// Subscribe to data frames on the connection
useEffect(() => {
if (!connection) return;
const decoder = new TextDecoder('utf-8');
const channelListenerId = connection.addChannelListener(
(data: ArrayBuffer) => {
try {
const bytes = new Uint8Array(data);
const chunk = decoder.decode(bytes);
setShellData((prev) => prev + chunk);
} catch (e) {
console.warn('Failed to decode shell data', e);
}
},
);
return () => {
connection.removeChannelListener(channelListenerId);
};
}, [connection]);
const scrollViewRef = useRef<ScrollView | null>(null);
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [shellData]);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<Stack.Screen
options={{
headerLeft: () => (
<Pressable
onPress={async () => {
router.back();
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: '700' }}>
Back
</Text>
</Pressable>
),
headerRight: () => (
<Pressable
onPress={async () => {
try {
await connection?.disconnect();
} catch {}
router.replace('/shell');
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: '700' }}>
Disconnect
</Text>
</Pressable>
),
}}
/>
<View
style={[styles.container, { backgroundColor: theme.colors.background }]}
>
<View style={styles.terminal}>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.terminalContent}
keyboardShouldPersistTaps="handled"
>
<Text selectable style={styles.terminalText}>
{shellData || 'Connected. Output will appear here...'}
</Text>
</ScrollView>
</View>
<CommandInput
executeCommand={async (command) => {
await shell?.sendData(
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
);
}}
/>
</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>
<TextInput
testID="command-input"
style={styles.commandInput}
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={[styles.executeButton, { marginTop: 8 }]}
onPress={handleExecute}
testID="execute-button"
>
<Text style={styles.executeButtonText}>Execute</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0B1324',
padding: 16,
},
terminal: {
flex: 1,
backgroundColor: '#0E172B',
borderRadius: 12,
borderWidth: 1,
borderColor: '#2A3655',
overflow: 'hidden',
marginBottom: 12,
},
terminalContent: {
padding: 12,
},
terminalText: {
color: '#D1D5DB',
fontSize: 14,
lineHeight: 18,
fontFamily: Platform.select({
ios: 'Menlo',
android: 'monospace',
default: 'monospace',
}),
},
commandInput: {
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',
}),
},
executeButton: {
backgroundColor: '#2563EB',
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
},
executeButtonText: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 14,
},
});

View File

@@ -1,6 +1,19 @@
import { Stack } from 'expo-router';
import React from 'react'; import React from 'react';
import ShellStackLayout from '../../shell/_layout';
export default function TabsShellStack() { export default function TabsShellStack() {
return <ShellStackLayout />; return (
<Stack
screenOptions={{
headerBlurEffect: 'systemMaterial',
headerTransparent: true,
}}
>
<Stack.Screen name="index" options={{ title: 'Shells' }} />
<Stack.Screen
name="[connectionId]/[channelId]"
options={{ title: 'SSH Shell' }}
/>
</Stack>
);
} }

View File

@@ -1,6 +1,99 @@
import {
type RnRussh,
type SshConnection,
type SshShellSession,
} from '@fressh/react-native-uniffi-russh';
import { FlashList } from '@shopify/flash-list';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'expo-router';
import React from 'react'; import React from 'react';
import ShellList from '../../shell/index'; import { StyleSheet, Text, View } from 'react-native';
import { listSshShellsQueryOptions } from '@/lib/query-fns';
export default function TabsShellList() { export default function TabsShellList() {
return <ShellList />; return <ShellList />;
} }
type ShellWithConnection = SshShellSession & { connection: SshConnection };
function ShellList() {
const connectionsWithShells = useQuery(listSshShellsQueryOptions);
if (!connectionsWithShells.data) {
return <LoadingState />;
}
return <LoadedState connectionsWithShells={connectionsWithShells.data} />;
}
function LoadingState() {
return (
<View style={styles.container}>
<Text style={styles.text}>Loading...</Text>
</View>
);
}
function LoadedState({
connectionsWithShells,
}: {
connectionsWithShells: ReturnType<
typeof RnRussh.listSshConnectionsWithShells
>;
}) {
const shellsFirstList = connectionsWithShells.reduce<ShellWithConnection[]>(
(acc, curr) => {
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr })));
return acc;
},
[],
);
return (
<View style={{ flex: 1 }}>
{shellsFirstList.length === 0 ? (
<EmptyState />
) : (
<FlashList
data={shellsFirstList}
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
renderItem={({ item }) => <ShellCard shell={item} />}
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
contentContainerStyle={{ paddingVertical: 16 }}
style={{ flex: 1 }}
/>
)}
</View>
);
}
function EmptyState() {
return (
<View style={styles.container}>
<Text style={styles.text}>No active shells. Connect from Host tab.</Text>
<Link href="/">Go to Host</Link>
</View>
);
}
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: 'black', marginBottom: 8 },
});

View File

@@ -1,575 +0,0 @@
import { RnRussh } from '@fressh/react-native-uniffi-russh';
import SegmentedControl from '@react-native-segmented-control/segmented-control';
import { useStore } from '@tanstack/react-form';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import React from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
Modal,
} from 'react-native';
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 {
type ConnectionDetails,
connectionDetailsSchema,
secretsManager,
} from '../lib/secrets-manager';
// import { sshConnectionManager } from '../lib/ssh-connection-manager';
import { useTheme } from '../theme';
const defaultValues: ConnectionDetails = {
host: 'test.rebex.net',
port: 22,
username: 'demo',
security: {
type: 'password',
password: 'password',
},
};
const useSshConnMutation = () => {
const router = useRouter();
return useMutation({
mutationFn: async (connectionDetails: ConnectionDetails) => {
try {
console.log('Connecting to SSH server...');
const sshConnection = await RnRussh.connect({
host: connectionDetails.host,
port: connectionDetails.port,
username: connectionDetails.username,
security:
connectionDetails.security.type === 'password'
? {
type: 'password',
password: connectionDetails.security.password,
}
: { type: 'key', privateKey: 'TODO' },
onStatusChange: (status) => {
console.log('SSH connection status', status);
},
abortSignal: AbortSignalTimeout(5_000),
});
await secretsManager.connections.utils.upsertConnection({
id: 'default',
details: connectionDetails,
priority: 0,
});
const shellInterface = await sshConnection.startShell({
pty: 'Xterm',
onStatusChange: (status) => {
console.log('SSH shell status', status);
},
abortSignal: AbortSignalTimeout(5_000),
});
const channelId = shellInterface.channelId as number;
const connectionId =
sshConnection.connectionId ??
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
console.log('Connected to SSH server', connectionId, channelId);
router.push({
pathname: '/shell/[connectionId]/[channelId]',
params: {
connectionId: connectionId,
channelId: String(channelId),
},
});
} catch (error) {
console.error('Error connecting to SSH server', error);
throw error;
}
},
});
};
export default function Host() {
const theme = useTheme();
const insets = useSafeAreaInsets();
const sshConnMutation = useSshConnMutation();
const connectionForm = useAppForm({
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
defaultValues,
validators: {
onChange: connectionDetailsSchema,
onSubmitAsync: async ({ value }) => sshConnMutation.mutateAsync(value),
},
});
const securityType = useStore(
connectionForm.store,
(state) => state.values.security.type,
);
const isSubmitting = useStore(
connectionForm.store,
(state) => state.isSubmitting,
);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: Math.max(32, insets.bottom + 24) },
]}
keyboardShouldPersistTaps="handled"
style={{ backgroundColor: theme.colors.background }}
>
<View
style={[
styles.container,
{ backgroundColor: theme.colors.background },
]}
>
<View style={styles.header}>
<Text style={styles.appName}>fressh</Text>
<Text style={styles.appTagline}>A fast, friendly SSH client</Text>
</View>
<View style={styles.card}>
<Text style={styles.title}>Connect to SSH Server</Text>
<Text style={styles.subtitle}>Enter your server credentials</Text>
<connectionForm.AppForm>
<connectionForm.AppField name="host">
{(field) => (
<field.TextField
label="Host"
testID="host"
placeholder="example.com or 192.168.0.10"
autoCapitalize="none"
autoCorrect={false}
/>
)}
</connectionForm.AppField>
<connectionForm.AppField name="port">
{(field) => (
<field.NumberField
label="Port"
placeholder="22"
testID="port"
/>
)}
</connectionForm.AppField>
<connectionForm.AppField name="username">
{(field) => (
<field.TextField
label="Username"
testID="username"
placeholder="root"
autoCapitalize="none"
autoCorrect={false}
/>
)}
</connectionForm.AppField>
<connectionForm.AppField name="security.type">
{(field) => (
<View style={styles.inputGroup}>
<SegmentedControl
values={['Password', 'Private Key']}
selectedIndex={field.state.value === 'password' ? 0 : 1}
onChange={(event) => {
field.handleChange(
event.nativeEvent.selectedSegmentIndex === 0
? 'password'
: 'key',
);
}}
/>
</View>
)}
</connectionForm.AppField>
{securityType === 'password' ? (
<connectionForm.AppField name="security.password">
{(field) => (
<field.TextField
label="Password"
testID="password"
placeholder="••••••••"
secureTextEntry
/>
)}
</connectionForm.AppField>
) : (
<connectionForm.AppField name="security.keyId">
{() => <KeyIdPickerField />}
</connectionForm.AppField>
)}
<View style={styles.actions}>
<connectionForm.SubmitButton
title="Connect"
testID="connect"
onPress={() => {
if (isSubmitting) return;
void connectionForm.handleSubmit();
}}
/>
</View>
</connectionForm.AppForm>
</View>
<PreviousConnectionsSection
onSelect={(connection) => {
connectionForm.setFieldValue('host', connection.host);
connectionForm.setFieldValue('port', connection.port);
connectionForm.setFieldValue('username', connection.username);
connectionForm.setFieldValue(
'security.type',
connection.security.type,
);
if (connection.security.type === 'password') {
connectionForm.setFieldValue(
'security.password',
connection.security.password,
);
} else {
connectionForm.setFieldValue(
'security.keyId',
connection.security.keyId,
);
}
}}
/>
</View>
</ScrollView>
</SafeAreaView>
);
}
function KeyIdPickerField() {
const field = useFieldContext<string>();
const [open, setOpen] = React.useState(false);
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
const defaultPick = React.useMemo(() => {
const keys = listPrivateKeysQuery.data ?? [];
const def = keys.find((k) => k.metadata?.isDefault);
return def ?? keys[0];
}, [listPrivateKeysQuery.data]);
const keys = listPrivateKeysQuery.data ?? [];
const fieldValue = field.state.value;
const defaultPickId = defaultPick?.id;
const fieldHandleChange = field.handleChange;
React.useEffect(() => {
if (!fieldValue && defaultPickId) {
fieldHandleChange(defaultPickId);
}
}, [fieldValue, defaultPickId, fieldHandleChange]);
const computedSelectedId = field.state.value ?? defaultPick?.id;
const selected = keys.find((k) => k.id === computedSelectedId);
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None';
return (
<>
<View style={styles.inputGroup}>
<Text style={styles.label}>Private Key</Text>
<Pressable
style={[styles.input, { justifyContent: 'center' }]}
onPress={() => {
void listPrivateKeysQuery.refetch();
setOpen(true);
}}
>
<Text style={{ color: '#E5E7EB' }}>{display}</Text>
</Pressable>
{!selected && (
<Text style={styles.mutedText}>
Open Key Manager to add/select a key
</Text>
)}
</View>
<Modal
visible={open}
transparent
animationType="slide"
onRequestClose={() => setOpen(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.title}>Select Key</Text>
<Pressable
style={styles.modalCloseButton}
onPress={() => setOpen(false)}
>
<Text style={styles.modalCloseText}>Close</Text>
</Pressable>
</View>
<KeyList
mode="select"
onSelect={async (id) => {
field.handleChange(id);
setOpen(false);
}}
/>
</View>
</View>
</Modal>
</>
);
}
function PreviousConnectionsSection(props: {
onSelect: (connection: ConnectionDetails) => void;
}) {
const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
return (
<View style={styles.listSection}>
<Text style={styles.listTitle}>Previous Connections</Text>
{listConnectionsQuery.isLoading ? (
<Text style={styles.mutedText}>Loading connections...</Text>
) : listConnectionsQuery.isError ? (
<Text style={styles.errorText}>Error loading connections</Text>
) : listConnectionsQuery.data?.length ? (
<View style={styles.listContainer}>
{listConnectionsQuery.data?.map((conn) => (
<ConnectionRow
key={conn.id}
id={conn.id}
onSelect={props.onSelect}
/>
))}
</View>
) : (
<Text style={styles.mutedText}>No saved connections yet</Text>
)}
</View>
);
}
function ConnectionRow(props: {
id: string;
onSelect: (connection: ConnectionDetails) => void;
}) {
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
const details = detailsQuery.data?.value;
return (
<Pressable
style={styles.row}
onPress={() => {
if (details) props.onSelect(details);
}}
disabled={!details}
>
<View style={styles.rowTextContainer}>
<Text style={styles.rowTitle}>
{details ? `${details.username}@${details.host}` : 'Loading...'}
</Text>
<Text style={styles.rowSubtitle}>
{details ? `Port ${details.port}${details.security.type}` : ''}
</Text>
</View>
<Text style={styles.rowChevron}></Text>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
backgroundColor: '#0B1324',
justifyContent: 'center',
},
scrollContent: {
paddingBottom: 32,
},
header: {
marginBottom: 16,
alignItems: 'center',
},
appName: {
fontSize: 28,
fontWeight: '800',
color: '#E5E7EB',
letterSpacing: 1,
},
appTagline: {
marginTop: 4,
fontSize: 13,
color: '#9AA0A6',
},
card: {
backgroundColor: '#111B34',
borderRadius: 20,
padding: 24,
marginHorizontal: 4,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 16,
shadowOffset: { width: 0, height: 4 },
elevation: 8,
borderWidth: 1,
borderColor: '#1E293B',
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#E5E7EB',
marginBottom: 6,
letterSpacing: 0.5,
},
subtitle: {
fontSize: 15,
color: '#9AA0A6',
marginBottom: 24,
lineHeight: 20,
},
inputGroup: {
marginBottom: 12,
},
label: {
marginBottom: 6,
fontSize: 14,
color: '#C6CBD3',
fontWeight: '600',
},
input: {
borderWidth: 1,
borderColor: '#2A3655',
backgroundColor: '#0E172B',
color: '#E5E7EB',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
},
errorText: {
marginTop: 6,
color: '#FCA5A5',
fontSize: 12,
},
actions: {
marginTop: 20,
},
mutedText: {
color: '#9AA0A6',
fontSize: 14,
},
submitButton: {
backgroundColor: '#2563EB',
borderRadius: 12,
paddingVertical: 16,
alignItems: 'center',
shadowColor: '#2563EB',
shadowOpacity: 0.3,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 4,
},
submitButtonText: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 16,
letterSpacing: 0.5,
},
buttonDisabled: {
backgroundColor: '#3B82F6',
opacity: 0.6,
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#2A3655',
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
marginTop: 12,
},
secondaryButtonText: {
color: '#C6CBD3',
fontWeight: '600',
fontSize: 14,
letterSpacing: 0.3,
},
listSection: {
marginTop: 20,
},
listTitle: {
fontSize: 16,
fontWeight: '700',
color: '#E5E7EB',
marginBottom: 8,
},
listContainer: {
// Intentionally empty for RN compatibility
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#0E172B',
borderWidth: 1,
borderColor: '#2A3655',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
marginBottom: 8,
},
rowTextContainer: {
flex: 1,
marginRight: 12,
},
rowTitle: {
color: '#E5E7EB',
fontSize: 15,
fontWeight: '600',
},
rowSubtitle: {
color: '#9AA0A6',
marginTop: 2,
fontSize: 12,
},
rowChevron: {
color: '#9AA0A6',
fontSize: 22,
paddingHorizontal: 4,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'flex-end',
},
modalSheet: {
backgroundColor: '#0B1324',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 16,
borderColor: '#1E293B',
borderWidth: 1,
maxHeight: '85%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
modalCloseButton: {
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
borderWidth: 1,
borderColor: '#2A3655',
},
modalCloseText: {
color: '#C6CBD3',
fontWeight: '600',
},
});

View File

@@ -1,25 +0,0 @@
import { Link } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function Tab() {
return (
<View style={styles.container}>
<Text style={{ color: '#E5E7EB', marginBottom: 12 }}>Settings</Text>
<Link
href="/(tabs)/settings/key-manager"
style={{ color: '#60A5FA', marginBottom: 8 }}
>
Manage Keys
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#0B1324',
},
});

View File

@@ -1,198 +0,0 @@
import { RnRussh } from '@fressh/react-native-uniffi-russh';
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import {
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTheme } from '../../../theme';
export default function ShellDetail() {
const { connectionId, channelId } = useLocalSearchParams<{
connectionId: string;
channelId: string;
}>();
const router = useRouter();
const navigation = useNavigation();
const theme = useTheme();
const channelIdNum = Number(channelId);
const connection = RnRussh.getSshConnection(connectionId);
const shell = RnRussh.getSshShell(connectionId, channelIdNum);
const [shellData, setShellData] = useState('');
useEffect(() => {
navigation.setOptions({
title: 'SSH Shell',
headerRight: () => (
<Pressable
onPress={async () => {
try {
await connection?.disconnect();
} catch {}
router.replace('/shell');
}}
>
<Text style={{ color: theme.colors.primary, fontWeight: '700' }}>
Disconnect
</Text>
</Pressable>
),
});
}, [connection, navigation, router, theme.colors.primary]);
// Subscribe to data frames on the connection
useEffect(() => {
if (!connection) return;
const decoder = new TextDecoder('utf-8');
const channelListenerId = connection.addChannelListener(
(data: ArrayBuffer) => {
try {
const bytes = new Uint8Array(data);
const chunk = decoder.decode(bytes);
setShellData((prev) => prev + chunk);
} catch (e) {
console.warn('Failed to decode shell data', e);
}
},
);
return () => {
connection.removeChannelListener(channelListenerId);
};
}, [connection]);
const scrollViewRef = useRef<ScrollView | null>(null);
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [shellData]);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<View
style={[styles.container, { backgroundColor: theme.colors.background }]}
>
<View style={styles.terminal}>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.terminalContent}
keyboardShouldPersistTaps="handled"
>
<Text selectable style={styles.terminalText}>
{shellData || 'Connected. Output will appear here...'}
</Text>
</ScrollView>
</View>
<CommandInput
executeCommand={async (command) => {
await shell?.sendData(
Uint8Array.from(new TextEncoder().encode(command + '\n')).buffer,
);
}}
/>
</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>
<TextInput
testID="command-input"
style={styles.commandInput}
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={[styles.executeButton, { marginTop: 8 }]}
onPress={handleExecute}
testID="execute-button"
>
<Text style={styles.executeButtonText}>Execute</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0B1324',
padding: 16,
},
terminal: {
flex: 1,
backgroundColor: '#0E172B',
borderRadius: 12,
borderWidth: 1,
borderColor: '#2A3655',
overflow: 'hidden',
marginBottom: 12,
},
terminalContent: {
padding: 12,
},
terminalText: {
color: '#D1D5DB',
fontSize: 14,
lineHeight: 18,
fontFamily: Platform.select({
ios: 'Menlo',
android: 'monospace',
default: 'monospace',
}),
},
commandInput: {
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',
}),
},
executeButton: {
backgroundColor: '#2563EB',
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
},
executeButtonText: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 14,
},
});

View File

@@ -1,18 +0,0 @@
import { Stack } from 'expo-router';
export default function ShellStackLayout() {
return (
<Stack
screenOptions={{
headerBlurEffect: 'systemMaterial',
headerTransparent: true,
}}
>
<Stack.Screen name="index" options={{ title: 'Shells' }} />
<Stack.Screen
name="[connectionId]/[channelId]"
options={{ title: 'SSH Shell' }}
/>
</Stack>
);
}

View File

@@ -1,67 +0,0 @@
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>
<Link href="/">Go to Host</Link>
</View>
);
}
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 },
});

View File

@@ -0,0 +1,68 @@
import { RnRussh } from '@fressh/react-native-uniffi-russh';
import { queryOptions, useMutation } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { secretsManager, type ConnectionDetails } from './secrets-manager';
import { AbortSignalTimeout } from './utils';
export const useSshConnMutation = () => {
const router = useRouter();
return useMutation({
mutationFn: async (connectionDetails: ConnectionDetails) => {
try {
console.log('Connecting to SSH server...');
const sshConnection = await RnRussh.connect({
host: connectionDetails.host,
port: connectionDetails.port,
username: connectionDetails.username,
security:
connectionDetails.security.type === 'password'
? {
type: 'password',
password: connectionDetails.security.password,
}
: { type: 'key', privateKey: 'TODO' },
onStatusChange: (status) => {
console.log('SSH connection status', status);
},
abortSignal: AbortSignalTimeout(5_000),
});
await secretsManager.connections.utils.upsertConnection({
id: 'default',
details: connectionDetails,
priority: 0,
});
const shellInterface = await sshConnection.startShell({
pty: 'Xterm',
onStatusChange: (status) => {
console.log('SSH shell status', status);
},
abortSignal: AbortSignalTimeout(5_000),
});
const channelId = shellInterface.channelId as number;
const connectionId =
sshConnection.connectionId ??
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
console.log('Connected to SSH server', connectionId, channelId);
router.push({
pathname: '/shell/[connectionId]/[channelId]',
params: {
connectionId: connectionId,
channelId: String(channelId),
},
});
} catch (error) {
console.error('Error connecting to SSH server', error);
throw error;
}
},
});
};
export const listSshShellsQueryOptions = queryOptions({
queryKey: ['ssh-shells'],
queryFn: () => RnRussh.listSshConnectionsWithShells(),
})