Files
paseo/packages/app/src/components/add-host-modal.tsx

299 lines
8.6 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
}));
function isHostPortOnly(raw: string): boolean {
return !raw.includes("://") && !raw.includes("/");
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(details: Array<string | null>): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return `${unique[0]} (no additional details provided)`;
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(
endpoint: string,
error: unknown,
): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`;
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network.";
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = "Connection refused. Is the server running at this address?";
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
}
return { title, detail, raw };
}
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function AddHostModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: AddHostModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
const [endpointRaw, setEndpointRaw] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const handleClose = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = endpointRaw.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
}
if (!isHostPortOnly(raw)) {
setErrorMessage("Enter host:port only (no ws://, no /ws)");
return;
}
let endpoint: string;
try {
endpoint = normalizeHostPort(raw);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid host:port";
setErrorMessage(message);
return;
}
try {
setIsSaving(true);
setErrorMessage("");
const { client, serverId, hostname } = await connectToDaemon({
id: "probe",
type: "directTcp",
endpoint,
});
await client.close().catch(() => undefined);
if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Wrong daemon", message);
}
return;
}
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
const profile = await upsertDirectConnection({
serverId,
endpoint,
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
const combined =
raw && detail && raw !== detail
? `${title}\n${detail}\nDetails: ${raw}`
: detail
? `${title}\n${detail}`
: title;
setErrorMessage(combined);
if (!isMobile) {
// Desktop/web: also surface it as a dialog for quick visibility.
Alert.alert("Connection failed", combined);
}
} finally {
setIsSaving(false);
}
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
return (
<AdaptiveModalSheet
title="Direct connection"
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.field}>
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
ref={hostInputRef}
value={endpointRaw}
onChangeText={setEndpointRaw}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={() => void handleSave()}
/>
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
<View style={styles.actions}>
<Button style={{ flex: 1 }} variant="secondary" onPress={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button
style={{ flex: 1 }}
variant="default"
onPress={() => void handleSave()}
disabled={isSaving}
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
>
{isSaving ? "Connecting..." : "Connect"}
</Button>
</View>
</AdaptiveModalSheet>
);
}