diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index f1b2262..061f430 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,8 +1,15 @@ 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 { Modal, Pressable, ScrollView, Text, View } from 'react-native'; +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'; @@ -13,25 +20,43 @@ import { type InputConnectionDetails, } from '@/lib/secrets-manager'; import { useTheme } from '@/lib/theme'; +import { useBottomTabPadding } from '@/lib/useBottomTabPadding'; +// Map connection status literals to human-friendly labels +const SSH_STATUS_LABELS: Record = { + tcpConnecting: 'Connecting to host…', + tcpConnected: 'Network connected', + tcpDisconnected: 'Network disconnected', + shellConnecting: 'Starting shell…', + shellConnected: 'Connected', + shellDisconnected: 'Shell disconnected', +} as const; export default function TabsIndex() { return ; } const defaultValues: InputConnectionDetails = { - host: 'test.rebex.net', + host: '', port: 22, - username: 'demo', + username: '', security: { type: 'password', - password: 'password', + password: '', }, }; function Host() { const theme = useTheme(); // const insets = useSafeAreaInsets(); - const sshConnMutation = useSshConnMutation(); + const [status, setStatus] = React.useState(null); + const sshConnMutation = useSshConnMutation({ + onStatusChange: (s) => { + // Hide banner immediately after shell connects + if (s === 'shellConnected') setStatus(null); + else setStatus(s); + }, + }); + const { paddingBottom, onLayout } = useBottomTabPadding(12); const connectionForm = useAppForm({ // https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values defaultValues, @@ -45,6 +70,11 @@ function Host() { connectionForm.store, (state) => state.values.security.type, ); + const formErrors = useStore(connectionForm.store, (state) => state.errorMap); + useEffect(() => { + if (!formErrors || Object.keys(formErrors).length === 0) return; + console.log('formErrors', JSON.stringify(formErrors, null, 2)); + }, [formErrors]); const isSubmitting = useStore( connectionForm.store, @@ -54,9 +84,10 @@ function Host() { return ( - - Connect to SSH Server - - - Enter your server credentials - + {/* Status lives inside the Connect button via submittingTitle */} @@ -192,34 +203,44 @@ function Host() { { + console.log('Connect button pressed', { isSubmitting }); if (isSubmitting) return; void connectionForm.handleSubmit(); }} /> + {sshConnMutation.isError ? ( + + {String( + (sshConnMutation.error as Error)?.message ?? + 'Failed to connect', + )} + + ) : null} { + onFillForm={(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, ); + connectionForm.setFieldValue('security.type', 'password'); } else { connectionForm.setFieldValue( 'security.keyId', connection.security.keyId, ); + connectionForm.setFieldValue('security.type', 'key'); } }} /> @@ -255,6 +276,14 @@ function KeyIdPickerField() { 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 ( <> @@ -294,6 +323,13 @@ function KeyIdPickerField() { )} + {fieldError ? ( + + {fieldError} + + ) : null} void; + onFillForm: (connection: InputConnectionDetails) => void; }) { const theme = useTheme(); const listConnectionsQuery = useQuery(secretsManager.connections.query.list); @@ -407,7 +443,7 @@ function PreviousConnectionsSection(props: { ))} @@ -422,11 +458,15 @@ function PreviousConnectionsSection(props: { function ConnectionRow(props: { id: string; - onSelect: (connection: InputConnectionDetails) => void; + 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.onSelect(details); + if (details) props.onFillForm(details); }} disabled={!details} > @@ -461,15 +501,244 @@ function ConnectionRow(props: { {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({ + id: newId, + details, + priority: 0, + }); + await secretsManager.connections.utils.deleteConnection( + props.id, + ); + 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 + + + + + + ); } diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index 3f1486b..b542fd1 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -118,9 +118,9 @@ function ShellDetail() { style={{ flex: 1, backgroundColor: theme.colors.background, - padding: 12, + padding: 0, paddingBottom: - 12 + insets.bottom + (bottomExtra || estimatedTabBarHeight), + 4 + insets.bottom + (bottomExtra || estimatedTabBarHeight), }} > 0 ? String(errs[0]) : null; + let errorMessage: string | null = null; + if (errs && errs.length > 0) { + const first = errs[0] as { message: string }; + if ( + first && + typeof first === 'object' && + typeof first.message === 'string' + ) { + errorMessage = first.message as string; + } else { + errorMessage = String(first); + } + } return ( @@ -161,10 +173,17 @@ export function SubmitButton( props: { onPress?: () => void; title?: string; + submittingTitle?: string; disabled?: boolean; } & React.ComponentProps, ) { - const { onPress, title = 'Connect', disabled, ...rest } = props; + const { + onPress, + title = 'Connect', + submittingTitle, + disabled, + ...rest + } = props; const formContext = useFormContext(); const isSubmitting = useStore( formContext.store, @@ -186,7 +205,7 @@ export function SubmitButton( disabled={disabled === true ? true : isSubmitting} > - {isSubmitting ? 'Connecting...' : title} + {isSubmitting ? (submittingTitle ?? 'Connecting...') : title} ); diff --git a/apps/mobile/src/lib/query-fns.ts b/apps/mobile/src/lib/query-fns.ts index 966a168..1e90c20 100644 --- a/apps/mobile/src/lib/query-fns.ts +++ b/apps/mobile/src/lib/query-fns.ts @@ -10,7 +10,9 @@ import { secretsManager, type InputConnectionDetails } from './secrets-manager'; import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store'; import { AbortSignalTimeout } from './utils'; -export const useSshConnMutation = () => { +export const useSshConnMutation = (opts?: { + onStatusChange?: (status: string) => void; +}) => { const router = useRouter(); const queryClient = useQueryClient(); @@ -39,6 +41,7 @@ export const useSshConnMutation = () => { security, onStatusChange: (status) => { console.log('SSH connection status', status); + opts?.onStatusChange?.(status); }, abortSignal: AbortSignalTimeout(5_000), }); @@ -56,6 +59,7 @@ export const useSshConnMutation = () => { if (keyRef) useSshStore.getState().setStatus(keyRef, toSessionStatus(status)); console.log('SSH shell status', status); + opts?.onStatusChange?.(status); }, abortSignal: AbortSignalTimeout(5_000), }); diff --git a/apps/mobile/src/lib/useBottomTabPadding.ts b/apps/mobile/src/lib/useBottomTabPadding.ts new file mode 100644 index 0000000..06ef485 --- /dev/null +++ b/apps/mobile/src/lib/useBottomTabPadding.ts @@ -0,0 +1,36 @@ +import React from 'react'; +import { Dimensions, Platform } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type LayoutEvent = { + nativeEvent: { + layout: { + y: number; + height: number; + }; + }; +}; + +export function useBottomTabPadding(basePadding = 12) { + const insets = useSafeAreaInsets(); + const windowH = Dimensions.get('window').height; + const estimatedTabBarHeight = Platform.select({ + ios: 49, + android: 80, + default: 56, + }); + const [bottomExtra, setBottomExtra] = React.useState(0); + + const onLayout = React.useCallback( + (e: LayoutEvent) => { + const { y, height } = e.nativeEvent.layout; + const extra = windowH - (y + height); + setBottomExtra(extra > 0 ? extra : 0); + }, + [windowH], + ); + + const paddingBottom = + basePadding + insets.bottom + (bottomExtra || estimatedTabBarHeight!); + return { paddingBottom, onLayout } as const; +}