Files
paseo/packages/app/src/components/add-host-modal.tsx
T
Mohamed BoudraandGitHub a3951495d4 refactor(app): improve terminal profile modal form and zinc button contrast
- Extract terminal profile add/edit modal into a reusable component with shared FormField/FormTextInput primitives.
- Add inline validation, save-pending state, and keyboard navigation.
- Fix zinc theme primary button foreground contrast by using accentForeground/destructiveForeground tokens.

Closes #1484
2026-06-12 19:30:14 +08:00

657 lines
20 KiB
TypeScript

import { useCallback, useMemo, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import {
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
interface DirectConnectionDraft {
host: string;
port: string;
useTls: boolean;
password: string;
}
interface PreparedDirectConnection {
uri: string;
endpoint: string;
useTls: boolean;
password?: string;
}
interface DirectConnectionLabels {
hostRequired: string;
invalidPort: string;
invalidConnection: string;
failedToConnect: (endpoint: string) => string;
noAdditionalDetails: (detail: string) => string;
timedOut: string;
refused: string;
hostNotFound: string;
hostUnreachable: string;
tlsError: string;
unableToConnect: string;
}
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,
},
portRow: {
flexDirection: "row",
gap: theme.spacing[3],
},
hostField: {
flex: 1,
minWidth: 0,
},
portField: {
width: 112,
},
passwordRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
passwordInput: {
flex: 1,
minWidth: 0,
},
iconButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
},
checkboxRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
advancedToggle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1],
},
advancedText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
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 isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
host,
port,
isIpv6: isIpv6Host(host),
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
function prepareDirectConnection(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
return {
uri: serializeConnectionUri(parsed),
endpoint,
useTls: parsed.useTls,
...(parsed.password ? { password: parsed.password } : {}),
};
}
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
const parsed = parseConnectionUri(uri);
return {
host: parsed.host,
port: String(parsed.port),
useTls: parsed.useTls,
password: parsed.password ?? "",
};
}
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: (string | null)[],
labels: DirectConnectionLabels,
): 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 labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: DirectConnectionLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState("6767");
const [useTls, setUseTls] = useState(false);
const [password, setPassword] = useState("");
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => {
setHost("");
setPort("6767");
setUseTls(false);
setPassword("");
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
bumpInputResetKey();
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
() => [styles.checkbox, useTls ? styles.checkboxChecked : null],
[useTls],
);
const passwordInputStyle = useMemo(() => [styles.input, styles.passwordInput], []);
const useTlsAccessibilityState = useMemo(
() => ({ checked: useTls, disabled: isSaving }),
[isSaving, useTls],
);
const directConnectionLabels = useMemo<DirectConnectionLabels>(
() => ({
hostRequired: t("pairing.direct.errors.hostRequired"),
invalidPort: t("pairing.direct.errors.invalidPort"),
invalidConnection: t("pairing.direct.errors.invalidConnection"),
failedToConnect: (endpoint) => t("pairing.direct.errors.failedToConnect", { endpoint }),
noAdditionalDetails: (detail) => t("pairing.direct.errors.noAdditionalDetails", { detail }),
timedOut: t("pairing.direct.errors.timedOut"),
refused: t("pairing.direct.errors.refused"),
hostNotFound: t("pairing.direct.errors.hostNotFound"),
hostUnreachable: t("pairing.direct.errors.hostUnreachable"),
tlsError: t("pairing.direct.errors.tlsError"),
unableToConnect: t("pairing.direct.errors.unableToConnect"),
}),
[t],
);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
const handleClose = useCallback(() => {
if (isSaving) return;
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, clearInput, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
let connection: PreparedDirectConnection;
try {
connection = prepareDirectConnection(
{ host, port, useTls, password },
directConnectionLabels,
);
} catch (error) {
const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
setErrorMessage(message);
return;
}
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertDirectConnection({
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
});
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const {
title,
detail,
raw: rawDetail,
} = buildConnectionFailureCopy({
endpoint: connection.uri,
error,
labels: directConnectionLabels,
});
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\n${t("pairing.direct.errors.details", {
detail: rawDetail,
})}`;
} else if (detail) {
combined = `${title}\n${detail}`;
} else {
combined = title;
}
setErrorMessage(combined);
if (!isMobile) {
Alert.alert(t("pairing.direct.errors.failedTitle"), combined);
}
} finally {
setIsSaving(false);
}
}, [
daemons,
directConnectionLabels,
handleClose,
host,
isMobile,
isSaving,
onSaved,
password,
port,
probeAndUpsertDirectConnection,
t,
useTls,
]);
const handleSubmitEditing = useCallback(() => {
void handleSave();
}, [handleSave]);
const handleSavePress = useCallback(() => {
void handleSave();
}, [handleSave]);
const handleToggleUseTls = useCallback(() => {
if (isSaving) return;
setUseTls((current) => !current);
}, [isSaving]);
const handleTogglePasswordVisibility = useCallback(() => {
setIsPasswordVisible((current) => !current);
}, []);
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
);
} catch {
setAdvancedUri("");
}
setErrorMessage("");
setIsAdvancedOpen(true);
return;
}
try {
const next = draftFromConnectionUri(advancedUri);
setHost(next.host);
setPort(next.port);
setUseTls(next.useTls);
setPassword(next.password);
setErrorMessage("");
bumpInputResetKey();
} catch {
setErrorMessage("");
}
setIsAdvancedOpen(false);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<Text style={styles.helper}>{t("pairing.direct.helper")}</Text>
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<AdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel={t("pairing.direct.fields.host")}
initialValue={host}
resetKey={`direct-host-${inputResetKey}`}
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="next"
/>
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<AdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel={t("pairing.direct.fields.port")}
initialValue={port}
resetKey={`direct-port-${inputResetKey}`}
value={port}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="number-pad"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
</View>
</View>
<Pressable
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
accessibilityRole="checkbox"
accessibilityLabel={t("pairing.direct.fields.useSsl")}
accessibilityState={useTlsAccessibilityState}
testID="direct-ssl-toggle"
>
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.accentForeground} />
</View>
) : null}
</View>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</Pressable>
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel={t("pairing.direct.fields.password")}
initialValue={password}
resetKey={`direct-password-${inputResetKey}`}
value={password}
onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!isPasswordVisible}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Pressable
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={
isPasswordVisible
? t("pairing.direct.passwordVisibility.hide")
: t("pairing.direct.passwordVisibility.show")
}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
<View style={styles.field}>
<Pressable
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={
isAdvancedOpen ? t("pairing.direct.advanced.hide") : t("pairing.direct.advanced.show")
}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
{isAdvancedOpen ? (
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
) : null}
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
variant="secondary"
onPress={handleCancel}
disabled={isSaving}
>
{t("pairing.direct.actions.cancel")}
</Button>
<Button
style={FLEX_ONE_STYLE}
variant="default"
onPress={handleSavePress}
disabled={isSaving}
leftIcon={connectIcon}
testID="direct-host-submit"
>
{isSaving ? t("pairing.direct.actions.connecting") : t("pairing.direct.actions.connect")}
</Button>
</View>
</AdaptiveModalSheet>
);
}