import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh';
import SegmentedControl from '@react-native-segmented-control/segmented-control';
import { useStore } from '@tanstack/react-form';
import { useQuery } from '@tanstack/react-query';
import React, { useEffect } from 'react';
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppForm, useFieldContext } from '@/components/form-components';
import { KeyList } from '@/components/key-manager/KeyList';
import { rootLogger } from '@/lib/logger';
import { useSshConnMutation } from '@/lib/query-fns';
import {
connectionDetailsSchema,
secretsManager,
type InputConnectionDetails,
} from '@/lib/secrets-manager';
import { useTheme } from '@/lib/theme';
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
const logger = rootLogger.extend('TabsIndex');
export default function TabsIndex() {
return ;
}
const defaultValues: InputConnectionDetails = {
host: '',
port: 22,
username: '',
security: {
type: 'password',
password: '',
},
};
function Host() {
const theme = useTheme();
const [lastConnectionProgressEvent, setLastConnectionProgressEvent] =
React.useState(null);
const sshConnMutation = useSshConnMutation({
onConnectionProgress: (s) => setLastConnectionProgressEvent(s),
});
const marginBottom = useBottomTabSpacing();
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).then(() => {
setLastConnectionProgressEvent(null);
}),
},
});
const securityType = useStore(
connectionForm.store,
(state) => state.values.security.type,
);
const formErrors = useStore(connectionForm.store, (state) => state.errorMap);
useEffect(() => {
if (!formErrors || Object.keys(formErrors).length === 0) return;
logger.info('formErrors', JSON.stringify(formErrors, null, 2));
}, [formErrors]);
const isSubmitting = useStore(
connectionForm.store,
(state) => state.isSubmitting,
);
const buttonLabel = (() => {
if (!sshConnMutation.isPending) return 'Connect';
if (lastConnectionProgressEvent === null) return 'TCP Connecting...';
if (lastConnectionProgressEvent === 'tcpConnected')
return 'SSH Handshake...';
if (lastConnectionProgressEvent === 'sshHandshake')
return 'Authenticating...';
return 'Connected!';
})();
return (
fressh
A fast, friendly SSH client
{/* Status lives inside the Connect button via submittingTitle */}
{(field) => (
)}
{(field) => (
)}
{(field) => (
)}
{(field) => (
{
field.handleChange(
event.nativeEvent.selectedSegmentIndex === 0
? 'password'
: 'key',
);
}}
/>
)}
{securityType === 'password' ? (
{(field) => (
)}
) : (
{() => }
)}
{
logger.info('Connect button pressed', { isSubmitting });
if (isSubmitting) return;
void connectionForm.handleSubmit();
}}
/>
{sshConnMutation.isError ? (
{String(
(sshConnMutation.error as Error)?.message ??
'Failed to connect',
)}
) : null}
{
connectionForm.setFieldValue('host', connection.host);
connectionForm.setFieldValue('port', connection.port);
connectionForm.setFieldValue('username', connection.username);
if (connection.security.type === 'password') {
connectionForm.setFieldValue(
'security.password',
connection.security.password,
);
connectionForm.setFieldValue('security.type', 'password');
} else {
connectionForm.setFieldValue(
'security.keyId',
connection.security.keyId,
);
connectionForm.setFieldValue('security.type', 'key');
}
}}
/>
);
}
function KeyIdPickerField() {
const theme = useTheme();
const field = useFieldContext();
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;
const selected = keys.find((k) => k.id === computedSelectedId);
const display = selected ? (selected.metadata.label ?? selected.id) : 'None';
const meta = field.state.meta as { errors?: unknown[] };
const firstErr = meta?.errors?.[0] as { message: string } | undefined;
const fieldError =
firstErr &&
typeof firstErr === 'object' &&
typeof firstErr.message === 'string'
? firstErr.message
: null;
return (
<>
Private Key
{
void listPrivateKeysQuery.refetch();
setOpen(true);
}}
>
{display}
{!selected && (
Open Key Manager to add/select a key
)}
{fieldError ? (
{fieldError}
) : null}
{
setOpen(false);
}}
>
Select Key
{
setOpen(false);
}}
>
Close
{
field.handleChange(id);
setOpen(false);
}}
/>
>
);
}
function PreviousConnectionsSection(props: {
onFillForm: (connection: InputConnectionDetails) => void;
}) {
const theme = useTheme();
const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
return (
Previous Connections
{listConnectionsQuery.isLoading ? (
Loading connections...
) : listConnectionsQuery.isError ? (
Error loading connections
) : listConnectionsQuery.data?.length ? (
{listConnectionsQuery.data.map((conn) => (
))}
) : (
No saved connections yet
)}
);
}
function ConnectionRow(props: {
id: string;
onFillForm: (connection: InputConnectionDetails) => void;
}) {
const theme = useTheme();
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
const details = detailsQuery.data?.value;
const [open, setOpen] = React.useState(false);
const [renameOpen, setRenameOpen] = React.useState(false);
const [newId, setNewId] = React.useState(props.id);
const listQuery = useQuery(secretsManager.connections.query.list);
return (
{
if (details) props.onFillForm(details);
}}
disabled={!details}
>
{details ? `${details.username}@${details.host}` : 'Loading...'}
{details ? `Port ${details.port} • ${details.security.type}` : ''}
setOpen(true)} hitSlop={8}>
⋯
{/* Actions Modal */}
setOpen(false)}
>
setOpen(false)}
>
Connection Actions
{/* Keep only rename/delete/cancel. Tap row fills the form */}
{
setOpen(false);
setRenameOpen(true);
setNewId(props.id);
}}
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
}}
>
Rename
{
setOpen(false);
await secretsManager.connections.utils.deleteConnection(
props.id,
);
await listQuery.refetch();
}}
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.danger,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
}}
>
Delete
setOpen(false)}
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
}}
>
Cancel
{/* Rename Modal */}
setRenameOpen(false)}
>
setRenameOpen(false)}
>
Rename Connection
Enter a new identifier for this saved connection
{
if (!details) return;
if (!newId || newId === props.id) {
setRenameOpen(false);
return;
}
// Recreate under new id then delete old
await secretsManager.connections.utils.upsertConnection({
details,
priority: 0,
label: newId,
});
await listQuery.refetch();
setRenameOpen(false);
}}
style={{
backgroundColor: theme.colors.primary,
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 16,
alignItems: 'center',
flex: 1,
}}
>
Save
setRenameOpen(false)}
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 16,
alignItems: 'center',
flex: 1,
}}
>
Cancel
);
}