more stuff working

This commit is contained in:
EthanShoeDev
2025-09-18 03:28:04 -04:00
parent 739fcb9b5f
commit 243e230d4d
5 changed files with 380 additions and 52 deletions

View File

@@ -1,8 +1,15 @@
import SegmentedControl from '@react-native-segmented-control/segmented-control'; import SegmentedControl from '@react-native-segmented-control/segmented-control';
import { useStore } from '@tanstack/react-form'; import { useStore } from '@tanstack/react-form';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React from 'react'; import React, { useEffect } from 'react';
import { Modal, Pressable, ScrollView, Text, View } from 'react-native'; import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppForm, useFieldContext } from '@/components/form-components'; import { useAppForm, useFieldContext } from '@/components/form-components';
import { KeyList } from '@/components/key-manager/KeyList'; import { KeyList } from '@/components/key-manager/KeyList';
@@ -13,25 +20,43 @@ import {
type InputConnectionDetails, type InputConnectionDetails,
} from '@/lib/secrets-manager'; } from '@/lib/secrets-manager';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { useBottomTabPadding } from '@/lib/useBottomTabPadding';
// Map connection status literals to human-friendly labels
const SSH_STATUS_LABELS: Record<string, string> = {
tcpConnecting: 'Connecting to host…',
tcpConnected: 'Network connected',
tcpDisconnected: 'Network disconnected',
shellConnecting: 'Starting shell…',
shellConnected: 'Connected',
shellDisconnected: 'Shell disconnected',
} as const;
export default function TabsIndex() { export default function TabsIndex() {
return <Host />; return <Host />;
} }
const defaultValues: InputConnectionDetails = { const defaultValues: InputConnectionDetails = {
host: 'test.rebex.net', host: '',
port: 22, port: 22,
username: 'demo', username: '',
security: { security: {
type: 'password', type: 'password',
password: 'password', password: '',
}, },
}; };
function Host() { function Host() {
const theme = useTheme(); const theme = useTheme();
// const insets = useSafeAreaInsets(); // const insets = useSafeAreaInsets();
const sshConnMutation = useSshConnMutation(); const [status, setStatus] = React.useState<string | null>(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({ const connectionForm = useAppForm({
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values // https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
defaultValues, defaultValues,
@@ -45,6 +70,11 @@ function Host() {
connectionForm.store, connectionForm.store,
(state) => state.values.security.type, (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( const isSubmitting = useStore(
connectionForm.store, connectionForm.store,
@@ -54,9 +84,10 @@ function Host() {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}> <SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<ScrollView <ScrollView
contentContainerStyle={[{ paddingBottom: 32 }]} contentContainerStyle={[{ paddingBottom }]}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
style={{ backgroundColor: theme.colors.background }} style={{ backgroundColor: theme.colors.background }}
onLayout={onLayout}
> >
<View <View
style={[ style={[
@@ -101,27 +132,7 @@ function Host() {
borderColor: theme.colors.borderStrong, borderColor: theme.colors.borderStrong,
}} }}
> >
<Text {/* Status lives inside the Connect button via submittingTitle */}
style={{
fontSize: 24,
fontWeight: '700',
color: theme.colors.textPrimary,
marginBottom: 6,
letterSpacing: 0.5,
}}
>
Connect to SSH Server
</Text>
<Text
style={{
fontSize: 15,
color: theme.colors.muted,
marginBottom: 24,
lineHeight: 20,
}}
>
Enter your server credentials
</Text>
<connectionForm.AppForm> <connectionForm.AppForm>
<connectionForm.AppField name="host"> <connectionForm.AppField name="host">
@@ -192,34 +203,44 @@ function Host() {
<View style={{ marginTop: 20 }}> <View style={{ marginTop: 20 }}>
<connectionForm.SubmitButton <connectionForm.SubmitButton
title="Connect" title="Connect"
submittingTitle={
SSH_STATUS_LABELS[status ?? ''] ?? 'Connecting…'
}
testID="connect" testID="connect"
onPress={() => { onPress={() => {
console.log('Connect button pressed', { isSubmitting });
if (isSubmitting) return; if (isSubmitting) return;
void connectionForm.handleSubmit(); void connectionForm.handleSubmit();
}} }}
/> />
</View> </View>
{sshConnMutation.isError ? (
<Text style={{ color: theme.colors.danger, marginTop: 8 }}>
{String(
(sshConnMutation.error as Error)?.message ??
'Failed to connect',
)}
</Text>
) : null}
</connectionForm.AppForm> </connectionForm.AppForm>
</View> </View>
<PreviousConnectionsSection <PreviousConnectionsSection
onSelect={(connection) => { onFillForm={(connection) => {
connectionForm.setFieldValue('host', connection.host); connectionForm.setFieldValue('host', connection.host);
connectionForm.setFieldValue('port', connection.port); connectionForm.setFieldValue('port', connection.port);
connectionForm.setFieldValue('username', connection.username); connectionForm.setFieldValue('username', connection.username);
connectionForm.setFieldValue(
'security.type',
connection.security.type,
);
if (connection.security.type === 'password') { if (connection.security.type === 'password') {
connectionForm.setFieldValue( connectionForm.setFieldValue(
'security.password', 'security.password',
connection.security.password, connection.security.password,
); );
connectionForm.setFieldValue('security.type', 'password');
} else { } else {
connectionForm.setFieldValue( connectionForm.setFieldValue(
'security.keyId', 'security.keyId',
connection.security.keyId, connection.security.keyId,
); );
connectionForm.setFieldValue('security.type', 'key');
} }
}} }}
/> />
@@ -255,6 +276,14 @@ function KeyIdPickerField() {
const computedSelectedId = field.state.value; const computedSelectedId = field.state.value;
const selected = keys.find((k) => k.id === computedSelectedId); const selected = keys.find((k) => k.id === computedSelectedId);
const display = selected ? (selected.metadata.label ?? selected.id) : 'None'; 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 ( return (
<> <>
@@ -294,6 +323,13 @@ function KeyIdPickerField() {
</Text> </Text>
)} )}
</View> </View>
{fieldError ? (
<Text
style={{ color: theme.colors.danger, fontSize: 12, marginTop: 6 }}
>
{fieldError}
</Text>
) : null}
<Modal <Modal
visible={open} visible={open}
transparent transparent
@@ -374,7 +410,7 @@ function KeyIdPickerField() {
} }
function PreviousConnectionsSection(props: { function PreviousConnectionsSection(props: {
onSelect: (connection: InputConnectionDetails) => void; onFillForm: (connection: InputConnectionDetails) => void;
}) { }) {
const theme = useTheme(); const theme = useTheme();
const listConnectionsQuery = useQuery(secretsManager.connections.query.list); const listConnectionsQuery = useQuery(secretsManager.connections.query.list);
@@ -407,7 +443,7 @@ function PreviousConnectionsSection(props: {
<ConnectionRow <ConnectionRow
key={conn.id} key={conn.id}
id={conn.id} id={conn.id}
onSelect={props.onSelect} onFillForm={props.onFillForm}
/> />
))} ))}
</View> </View>
@@ -422,11 +458,15 @@ function PreviousConnectionsSection(props: {
function ConnectionRow(props: { function ConnectionRow(props: {
id: string; id: string;
onSelect: (connection: InputConnectionDetails) => void; onFillForm: (connection: InputConnectionDetails) => void;
}) { }) {
const theme = useTheme(); const theme = useTheme();
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id)); const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
const details = detailsQuery.data?.value; 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 ( return (
<Pressable <Pressable
@@ -443,7 +483,7 @@ function ConnectionRow(props: {
marginBottom: 8, marginBottom: 8,
}} }}
onPress={() => { onPress={() => {
if (details) props.onSelect(details); if (details) props.onFillForm(details);
}} }}
disabled={!details} disabled={!details}
> >
@@ -461,15 +501,244 @@ function ConnectionRow(props: {
{details ? `Port ${details.port}${details.security.type}` : ''} {details ? `Port ${details.port}${details.security.type}` : ''}
</Text> </Text>
</View> </View>
<Text <Pressable onPress={() => setOpen(true)} hitSlop={8}>
style={{ <Text
color: theme.colors.muted, style={{
fontSize: 22, color: theme.colors.muted,
paddingHorizontal: 4, fontSize: 22,
}} paddingHorizontal: 4,
}}
>
</Text>
</Pressable>
{/* Actions Modal */}
<Modal
transparent
visible={open}
animationType="fade"
onRequestClose={() => setOpen(false)}
> >
<Pressable
</Text> style={{ flex: 1, backgroundColor: theme.colors.overlay }}
onPress={() => setOpen(false)}
>
<View
style={{
marginTop: 'auto',
backgroundColor: theme.colors.background,
padding: 16,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderWidth: 1,
borderColor: theme.colors.borderStrong,
}}
>
<Text
style={{
color: theme.colors.textPrimary,
fontWeight: '700',
fontSize: 16,
marginBottom: 12,
}}
>
Connection Actions
</Text>
<View style={{ gap: 8 }}>
{/* Keep only rename/delete/cancel. Tap row fills the form */}
<Pressable
onPress={() => {
setOpen(false);
setRenameOpen(true);
setNewId(props.id);
}}
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
}}
>
<Text
style={{
color: theme.colors.textSecondary,
fontWeight: '600',
}}
>
Rename
</Text>
</Pressable>
<Pressable
onPress={async () => {
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',
}}
>
<Text style={{ color: theme.colors.danger, fontWeight: '700' }}>
Delete
</Text>
</Pressable>
<Pressable
onPress={() => setOpen(false)}
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
}}
>
<Text
style={{
color: theme.colors.textSecondary,
fontWeight: '600',
}}
>
Cancel
</Text>
</Pressable>
</View>
</View>
</Pressable>
</Modal>
{/* Rename Modal */}
<Modal
transparent
visible={renameOpen}
animationType="fade"
onRequestClose={() => setRenameOpen(false)}
>
<Pressable
style={{ flex: 1, backgroundColor: theme.colors.overlay }}
onPress={() => setRenameOpen(false)}
>
<View
style={{
marginTop: 'auto',
backgroundColor: theme.colors.background,
padding: 16,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderWidth: 1,
borderColor: theme.colors.borderStrong,
}}
>
<Text
style={{
color: theme.colors.textPrimary,
fontWeight: '700',
fontSize: 16,
marginBottom: 8,
}}
>
Rename Connection
</Text>
<Text
style={{
color: theme.colors.muted,
fontSize: 12,
marginBottom: 8,
}}
>
Enter a new identifier for this saved connection
</Text>
<TextInput
value={newId}
onChangeText={setNewId}
autoCapitalize="none"
style={{
backgroundColor: theme.colors.inputBackground,
color: theme.colors.textPrimary,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 12,
}}
/>
<View style={{ flexDirection: 'row', gap: 8 }}>
<Pressable
onPress={async () => {
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,
}}
>
<Text
style={{
color: theme.colors.buttonTextOnPrimary,
fontWeight: '700',
textAlign: 'center',
}}
>
Save
</Text>
</Pressable>
<Pressable
onPress={() => setRenameOpen(false)}
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 16,
alignItems: 'center',
flex: 1,
}}
>
<Text
style={{
color: theme.colors.textSecondary,
fontWeight: '600',
textAlign: 'center',
}}
>
Cancel
</Text>
</Pressable>
</View>
</View>
</Pressable>
</Modal>
</Pressable> </Pressable>
); );
} }

View File

@@ -118,9 +118,9 @@ function ShellDetail() {
style={{ style={{
flex: 1, flex: 1,
backgroundColor: theme.colors.background, backgroundColor: theme.colors.background,
padding: 12, padding: 0,
paddingBottom: paddingBottom:
12 + insets.bottom + (bottomExtra || estimatedTabBarHeight), 4 + insets.bottom + (bottomExtra || estimatedTabBarHeight),
}} }}
> >
<Stack.Screen <Stack.Screen
@@ -165,7 +165,7 @@ function ShellDetail() {
// xterm options // xterm options
options={{ options={{
fontFamily: 'Menlo, ui-monospace, monospace', fontFamily: 'Menlo, ui-monospace, monospace',
fontSize: 18, fontSize: 80,
cursorBlink: true, cursorBlink: true,
scrollback: 10000, scrollback: 10000,
theme: { theme: {

View File

@@ -17,7 +17,19 @@ function FieldInfo() {
const field = useFieldContext(); const field = useFieldContext();
const meta = field.state.meta as { errors?: unknown[] }; const meta = field.state.meta as { errors?: unknown[] };
const errs = meta.errors; const errs = meta.errors;
const errorMessage = errs && errs.length > 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 ( return (
<View style={{ marginTop: 6 }}> <View style={{ marginTop: 6 }}>
@@ -161,10 +173,17 @@ export function SubmitButton(
props: { props: {
onPress?: () => void; onPress?: () => void;
title?: string; title?: string;
submittingTitle?: string;
disabled?: boolean; disabled?: boolean;
} & React.ComponentProps<typeof Pressable>, } & React.ComponentProps<typeof Pressable>,
) { ) {
const { onPress, title = 'Connect', disabled, ...rest } = props; const {
onPress,
title = 'Connect',
submittingTitle,
disabled,
...rest
} = props;
const formContext = useFormContext(); const formContext = useFormContext();
const isSubmitting = useStore( const isSubmitting = useStore(
formContext.store, formContext.store,
@@ -186,7 +205,7 @@ export function SubmitButton(
disabled={disabled === true ? true : isSubmitting} disabled={disabled === true ? true : isSubmitting}
> >
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 16 }}> <Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 16 }}>
{isSubmitting ? 'Connecting...' : title} {isSubmitting ? (submittingTitle ?? 'Connecting...') : title}
</Text> </Text>
</Pressable> </Pressable>
); );

View File

@@ -10,7 +10,9 @@ import { secretsManager, type InputConnectionDetails } from './secrets-manager';
import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store'; import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store';
import { AbortSignalTimeout } from './utils'; import { AbortSignalTimeout } from './utils';
export const useSshConnMutation = () => { export const useSshConnMutation = (opts?: {
onStatusChange?: (status: string) => void;
}) => {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -39,6 +41,7 @@ export const useSshConnMutation = () => {
security, security,
onStatusChange: (status) => { onStatusChange: (status) => {
console.log('SSH connection status', status); console.log('SSH connection status', status);
opts?.onStatusChange?.(status);
}, },
abortSignal: AbortSignalTimeout(5_000), abortSignal: AbortSignalTimeout(5_000),
}); });
@@ -56,6 +59,7 @@ export const useSshConnMutation = () => {
if (keyRef) if (keyRef)
useSshStore.getState().setStatus(keyRef, toSessionStatus(status)); useSshStore.getState().setStatus(keyRef, toSessionStatus(status));
console.log('SSH shell status', status); console.log('SSH shell status', status);
opts?.onStatusChange?.(status);
}, },
abortSignal: AbortSignalTimeout(5_000), abortSignal: AbortSignalTimeout(5_000),
}); });

View File

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