mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
more stuff working
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
36
apps/mobile/src/lib/useBottomTabPadding.ts
Normal file
36
apps/mobile/src/lib/useBottomTabPadding.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user