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 ); }