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
This commit is contained in:
@@ -319,8 +319,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connectIcon = useMemo(
|
const connectIcon = useMemo(
|
||||||
() => <Link2 size={16} color={theme.colors.palette.white} />,
|
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
||||||
[theme.colors.palette.white],
|
[theme.colors.accentForeground],
|
||||||
);
|
);
|
||||||
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
|
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
|
||||||
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
|
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pairIcon = useMemo(
|
const pairIcon = useMemo(
|
||||||
() => <Link size={16} color={theme.colors.palette.white} />,
|
() => <Link size={16} color={theme.colors.accentForeground} />,
|
||||||
[theme.colors.palette.white],
|
[theme.colors.accentForeground],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
|
|||||||
@@ -94,10 +94,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
textDefault: {
|
textDefault: {
|
||||||
color: theme.colors.palette.white,
|
color: theme.colors.accentForeground,
|
||||||
},
|
},
|
||||||
textDestructive: {
|
textDestructive: {
|
||||||
color: theme.colors.palette.white,
|
color: theme.colors.destructiveForeground,
|
||||||
},
|
},
|
||||||
textGhost: {
|
textGhost: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { forwardRef, useMemo, type ReactNode } from "react";
|
||||||
|
import { Text, View, type TextInput } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
hint?: string;
|
||||||
|
error?: string | null;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField({ label, children, hint, error, testID }: FormFieldProps) {
|
||||||
|
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
|
||||||
|
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
|
||||||
|
const hintOrError = useMemo(() => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Text style={styles.errorText} testID={errorTestID}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
return (
|
||||||
|
<Text style={styles.hintText} testID={hintTestID}>
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [error, hint, errorTestID, hintTestID]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID={testID}>
|
||||||
|
<Text style={styles.label}>{label}</Text>
|
||||||
|
{children}
|
||||||
|
{hintOrError}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(function FormTextInput(
|
||||||
|
{ style, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const inputStyle = useMemo(() => [formInputStyles.input, style], [style]);
|
||||||
|
return <AdaptiveTextInput ref={ref} {...props} style={inputStyle} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
hintText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.palette.red[300],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formInputStyles = StyleSheet.create((theme) => ({
|
||||||
|
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,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1642,7 +1642,10 @@ export const ar: TranslationResources = {
|
|||||||
nameLabel: "Name",
|
nameLabel: "Name",
|
||||||
commandLabel: "Command",
|
commandLabel: "Command",
|
||||||
argsLabel: "Arguments",
|
argsLabel: "Arguments",
|
||||||
|
nameRequired: "Name is required",
|
||||||
|
commandRequired: "Command is required",
|
||||||
argsHint: "Space-separated arguments passed to the command",
|
argsHint: "Space-separated arguments passed to the command",
|
||||||
|
saving: "Saving...",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
removeConfirmTitle: "Remove profile?",
|
removeConfirmTitle: "Remove profile?",
|
||||||
removeConfirmMessage: 'Remove "{{name}}"?',
|
removeConfirmMessage: 'Remove "{{name}}"?',
|
||||||
|
|||||||
@@ -1648,7 +1648,10 @@ export const en = {
|
|||||||
nameLabel: "Name",
|
nameLabel: "Name",
|
||||||
commandLabel: "Command",
|
commandLabel: "Command",
|
||||||
argsLabel: "Arguments",
|
argsLabel: "Arguments",
|
||||||
|
nameRequired: "Name is required",
|
||||||
|
commandRequired: "Command is required",
|
||||||
argsHint: "Space-separated arguments passed to the command",
|
argsHint: "Space-separated arguments passed to the command",
|
||||||
|
saving: "Saving...",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
removeConfirmTitle: "Remove profile?",
|
removeConfirmTitle: "Remove profile?",
|
||||||
removeConfirmMessage: 'Remove "{{name}}"?',
|
removeConfirmMessage: 'Remove "{{name}}"?',
|
||||||
|
|||||||
@@ -1680,7 +1680,10 @@ export const es: TranslationResources = {
|
|||||||
nameLabel: "Name",
|
nameLabel: "Name",
|
||||||
commandLabel: "Command",
|
commandLabel: "Command",
|
||||||
argsLabel: "Arguments",
|
argsLabel: "Arguments",
|
||||||
|
nameRequired: "Name is required",
|
||||||
|
commandRequired: "Command is required",
|
||||||
argsHint: "Space-separated arguments passed to the command",
|
argsHint: "Space-separated arguments passed to the command",
|
||||||
|
saving: "Saving...",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
removeConfirmTitle: "Remove profile?",
|
removeConfirmTitle: "Remove profile?",
|
||||||
removeConfirmMessage: 'Remove "{{name}}"?',
|
removeConfirmMessage: 'Remove "{{name}}"?',
|
||||||
|
|||||||
@@ -1685,7 +1685,10 @@ export const fr: TranslationResources = {
|
|||||||
nameLabel: "Name",
|
nameLabel: "Name",
|
||||||
commandLabel: "Command",
|
commandLabel: "Command",
|
||||||
argsLabel: "Arguments",
|
argsLabel: "Arguments",
|
||||||
|
nameRequired: "Name is required",
|
||||||
|
commandRequired: "Command is required",
|
||||||
argsHint: "Space-separated arguments passed to the command",
|
argsHint: "Space-separated arguments passed to the command",
|
||||||
|
saving: "Saving...",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
removeConfirmTitle: "Remove profile?",
|
removeConfirmTitle: "Remove profile?",
|
||||||
removeConfirmMessage: 'Remove "{{name}}"?',
|
removeConfirmMessage: 'Remove "{{name}}"?',
|
||||||
|
|||||||
@@ -1672,7 +1672,10 @@ export const ru: TranslationResources = {
|
|||||||
nameLabel: "Name",
|
nameLabel: "Name",
|
||||||
commandLabel: "Command",
|
commandLabel: "Command",
|
||||||
argsLabel: "Arguments",
|
argsLabel: "Arguments",
|
||||||
|
nameRequired: "Name is required",
|
||||||
|
commandRequired: "Command is required",
|
||||||
argsHint: "Space-separated arguments passed to the command",
|
argsHint: "Space-separated arguments passed to the command",
|
||||||
|
saving: "Saving...",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
removeConfirmTitle: "Remove profile?",
|
removeConfirmTitle: "Remove profile?",
|
||||||
removeConfirmMessage: 'Remove "{{name}}"?',
|
removeConfirmMessage: 'Remove "{{name}}"?',
|
||||||
|
|||||||
@@ -1623,7 +1623,10 @@ export const zhCN: TranslationResources = {
|
|||||||
nameLabel: "Name",
|
nameLabel: "Name",
|
||||||
commandLabel: "Command",
|
commandLabel: "Command",
|
||||||
argsLabel: "Arguments",
|
argsLabel: "Arguments",
|
||||||
|
nameRequired: "Name is required",
|
||||||
|
commandRequired: "Command is required",
|
||||||
argsHint: "Space-separated arguments passed to the command",
|
argsHint: "Space-separated arguments passed to the command",
|
||||||
|
saving: "Saving...",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
removeConfirmTitle: "Remove profile?",
|
removeConfirmTitle: "Remove profile?",
|
||||||
removeConfirmMessage: 'Remove "{{name}}"?',
|
removeConfirmMessage: 'Remove "{{name}}"?',
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ import {
|
|||||||
getTerminalProfileIcon,
|
getTerminalProfileIcon,
|
||||||
resolveTerminalProfiles,
|
resolveTerminalProfiles,
|
||||||
} from "@getpaseo/protocol/terminal-profiles";
|
} from "@getpaseo/protocol/terminal-profiles";
|
||||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
ProfileDraft,
|
||||||
|
TerminalProfileEditModal,
|
||||||
|
} from "@/screens/settings/terminal-profile-edit-modal";
|
||||||
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||||
@@ -1109,127 +1112,8 @@ function parseArgsString(raw: string): string[] | undefined {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProfileDraft {
|
|
||||||
name: string;
|
|
||||||
command: string;
|
|
||||||
args: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_PROFILE_DRAFT: ProfileDraft = { name: "", command: "", args: "" };
|
const EMPTY_PROFILE_DRAFT: ProfileDraft = { name: "", command: "", args: "" };
|
||||||
|
|
||||||
interface TerminalProfileEditModalProps {
|
|
||||||
visible: boolean;
|
|
||||||
title: string;
|
|
||||||
initialDraft: ProfileDraft;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (draft: ProfileDraft) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TerminalProfileEditModal({
|
|
||||||
visible,
|
|
||||||
title,
|
|
||||||
initialDraft,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
}: TerminalProfileEditModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [name, setName] = useState(initialDraft.name);
|
|
||||||
const [command, setCommand] = useState(initialDraft.command);
|
|
||||||
const [args, setArgs] = useState(initialDraft.args);
|
|
||||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title }), [title]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible) return;
|
|
||||||
setName(initialDraft.name);
|
|
||||||
setCommand(initialDraft.command);
|
|
||||||
setArgs(initialDraft.args);
|
|
||||||
}, [visible, initialDraft.name, initialDraft.command, initialDraft.args]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
onSave({ name, command, args });
|
|
||||||
}, [onSave, name, command, args]);
|
|
||||||
|
|
||||||
const canSave = name.trim().length > 0 && command.trim().length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdaptiveModalSheet
|
|
||||||
visible={visible}
|
|
||||||
header={sheetHeader}
|
|
||||||
onClose={onClose}
|
|
||||||
testID="terminal-profile-edit-modal"
|
|
||||||
desktopMaxWidth={480}
|
|
||||||
>
|
|
||||||
<View style={profileModalStyles.body}>
|
|
||||||
<View style={profileModalStyles.fieldGroup}>
|
|
||||||
<Text style={profileModalStyles.fieldLabel}>
|
|
||||||
{t("settings.host.terminalProfiles.nameLabel")}
|
|
||||||
</Text>
|
|
||||||
<AdaptiveTextInput
|
|
||||||
initialValue={initialDraft.name}
|
|
||||||
resetKey={visible ? "open" : "closed"}
|
|
||||||
onChangeText={setName}
|
|
||||||
placeholder={t("settings.host.terminalProfiles.namePlaceholder")}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
testID="terminal-profile-name-input"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={profileModalStyles.fieldGroup}>
|
|
||||||
<Text style={profileModalStyles.fieldLabel}>
|
|
||||||
{t("settings.host.terminalProfiles.commandLabel")}
|
|
||||||
</Text>
|
|
||||||
<AdaptiveTextInput
|
|
||||||
initialValue={initialDraft.command}
|
|
||||||
resetKey={visible ? "open" : "closed"}
|
|
||||||
onChangeText={setCommand}
|
|
||||||
placeholder={t("settings.host.terminalProfiles.commandPlaceholder")}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
testID="terminal-profile-command-input"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={profileModalStyles.fieldGroup}>
|
|
||||||
<Text style={profileModalStyles.fieldLabel}>
|
|
||||||
{t("settings.host.terminalProfiles.argsLabel")}
|
|
||||||
</Text>
|
|
||||||
<AdaptiveTextInput
|
|
||||||
initialValue={initialDraft.args}
|
|
||||||
resetKey={visible ? "open" : "closed"}
|
|
||||||
onChangeText={setArgs}
|
|
||||||
placeholder={t("settings.host.terminalProfiles.argsPlaceholder")}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
testID="terminal-profile-args-input"
|
|
||||||
/>
|
|
||||||
<Text style={profileModalStyles.fieldHint}>
|
|
||||||
{t("settings.host.terminalProfiles.argsHint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={profileModalStyles.actions}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
style={profileModalStyles.actionButton}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
{t("common.actions.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
style={profileModalStyles.actionButton}
|
|
||||||
onPress={handleSave}
|
|
||||||
disabled={!canSave}
|
|
||||||
testID="terminal-profile-save-button"
|
|
||||||
>
|
|
||||||
{t("settings.host.terminalProfiles.save")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</AdaptiveModalSheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalProfileRowProps {
|
interface TerminalProfileRowProps {
|
||||||
profile: TerminalProfile;
|
profile: TerminalProfile;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
@@ -1338,7 +1222,6 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
|
|||||||
draft: ProfileDraft;
|
draft: ProfileDraft;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const profiles = useMemo(
|
const profiles = useMemo(
|
||||||
() => (config ? resolveTerminalProfiles(config.terminalProfiles) : null),
|
() => (config ? resolveTerminalProfiles(config.terminalProfiles) : null),
|
||||||
@@ -1347,38 +1230,28 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
|
|||||||
|
|
||||||
const saveProfiles = useCallback(
|
const saveProfiles = useCallback(
|
||||||
async (next: TerminalProfile[]) => {
|
async (next: TerminalProfile[]) => {
|
||||||
setIsSaving(true);
|
await patchConfig({ terminalProfiles: next });
|
||||||
try {
|
|
||||||
await patchConfig({ terminalProfiles: next });
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert(
|
|
||||||
t("common.errors.unableToSave"),
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[patchConfig, t],
|
[patchConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddOpen = useCallback(() => setIsAdding(true), []);
|
const handleAddOpen = useCallback(() => setIsAdding(true), []);
|
||||||
const handleAddClose = useCallback(() => setIsAdding(false), []);
|
const handleAddClose = useCallback(() => setIsAdding(false), []);
|
||||||
|
|
||||||
const handleAddSave = useCallback(
|
const handleAddSave = useCallback(
|
||||||
(draft: ProfileDraft) => {
|
async (draft: ProfileDraft) => {
|
||||||
const current = profiles ? [...profiles] : [];
|
const current = profiles ? [...profiles] : [];
|
||||||
const next: TerminalProfile[] = [
|
const next: TerminalProfile[] = [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
id: generateProfileId(),
|
id: generateProfileId(),
|
||||||
name: draft.name.trim(),
|
name: draft.name,
|
||||||
command: draft.command.trim(),
|
command: draft.command,
|
||||||
args: parseArgsString(draft.args),
|
args: parseArgsString(draft.args),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
await saveProfiles(next);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
void saveProfiles(next);
|
|
||||||
},
|
},
|
||||||
[profiles, saveProfiles],
|
[profiles, saveProfiles],
|
||||||
);
|
);
|
||||||
@@ -1402,20 +1275,20 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
|
|||||||
const handleEditClose = useCallback(() => setEditingProfile(null), []);
|
const handleEditClose = useCallback(() => setEditingProfile(null), []);
|
||||||
|
|
||||||
const handleEditSave = useCallback(
|
const handleEditSave = useCallback(
|
||||||
(draft: ProfileDraft) => {
|
async (draft: ProfileDraft) => {
|
||||||
if (!editingProfile || !profiles) return;
|
if (!editingProfile || !profiles) return;
|
||||||
const next: TerminalProfile[] = profiles.map((p) =>
|
const next: TerminalProfile[] = profiles.map((p) =>
|
||||||
p.id === editingProfile.id
|
p.id === editingProfile.id
|
||||||
? {
|
? {
|
||||||
...p,
|
...p,
|
||||||
name: draft.name.trim(),
|
name: draft.name,
|
||||||
command: draft.command.trim(),
|
command: draft.command,
|
||||||
args: parseArgsString(draft.args),
|
args: parseArgsString(draft.args),
|
||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
);
|
);
|
||||||
|
await saveProfiles(next);
|
||||||
setEditingProfile(null);
|
setEditingProfile(null);
|
||||||
void saveProfiles(next);
|
|
||||||
},
|
},
|
||||||
[editingProfile, profiles, saveProfiles],
|
[editingProfile, profiles, saveProfiles],
|
||||||
);
|
);
|
||||||
@@ -1432,9 +1305,16 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
|
|||||||
confirmLabel: t("settings.host.terminalProfiles.remove"),
|
confirmLabel: t("settings.host.terminalProfiles.remove"),
|
||||||
cancelLabel: t("common.actions.cancel"),
|
cancelLabel: t("common.actions.cancel"),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
}).then((confirmed) => {
|
}).then(async (confirmed) => {
|
||||||
if (!confirmed || !profiles) return;
|
if (!confirmed || !profiles) return;
|
||||||
void saveProfiles(profiles.filter((p) => p.id !== id));
|
try {
|
||||||
|
await saveProfiles(profiles.filter((p) => p.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
t("common.errors.unableToSave"),
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1442,29 +1322,43 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleMoveUp = useCallback(
|
const handleMoveUp = useCallback(
|
||||||
(id: string) => {
|
async (id: string) => {
|
||||||
if (!profiles) return;
|
if (!profiles) return;
|
||||||
const index = profiles.findIndex((p) => p.id === id);
|
const index = profiles.findIndex((p) => p.id === id);
|
||||||
if (index <= 0) return;
|
if (index <= 0) return;
|
||||||
const next = [...profiles];
|
const next = [...profiles];
|
||||||
const [item] = next.splice(index, 1);
|
const [item] = next.splice(index, 1);
|
||||||
next.splice(index - 1, 0, item);
|
next.splice(index - 1, 0, item);
|
||||||
void saveProfiles(next);
|
try {
|
||||||
|
await saveProfiles(next);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
t("common.errors.unableToSave"),
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[profiles, saveProfiles],
|
[profiles, saveProfiles, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMoveDown = useCallback(
|
const handleMoveDown = useCallback(
|
||||||
(id: string) => {
|
async (id: string) => {
|
||||||
if (!profiles) return;
|
if (!profiles) return;
|
||||||
const index = profiles.findIndex((p) => p.id === id);
|
const index = profiles.findIndex((p) => p.id === id);
|
||||||
if (index < 0 || index >= profiles.length - 1) return;
|
if (index < 0 || index >= profiles.length - 1) return;
|
||||||
const next = [...profiles];
|
const next = [...profiles];
|
||||||
const [item] = next.splice(index, 1);
|
const [item] = next.splice(index, 1);
|
||||||
next.splice(index + 1, 0, item);
|
next.splice(index + 1, 0, item);
|
||||||
void saveProfiles(next);
|
try {
|
||||||
|
await saveProfiles(next);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert(
|
||||||
|
t("common.errors.unableToSave"),
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[profiles, saveProfiles],
|
[profiles, saveProfiles, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addButton = useMemo(
|
const addButton = useMemo(
|
||||||
@@ -1474,11 +1368,11 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={addProfileIcon}
|
leftIcon={addProfileIcon}
|
||||||
onPress={handleAddOpen}
|
onPress={handleAddOpen}
|
||||||
disabled={isSaving || !isConnected || !profiles}
|
disabled={!isConnected || !profiles}
|
||||||
testID="terminal-profiles-add-button"
|
testID="terminal-profiles-add-button"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[handleAddOpen, isSaving, isConnected, profiles],
|
[handleAddOpen, isConnected, profiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
@@ -1530,6 +1424,7 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
|
|||||||
initialDraft={EMPTY_PROFILE_DRAFT}
|
initialDraft={EMPTY_PROFILE_DRAFT}
|
||||||
onClose={handleAddClose}
|
onClose={handleAddClose}
|
||||||
onSave={handleAddSave}
|
onSave={handleAddSave}
|
||||||
|
testID="terminal-profile-edit-modal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{editingProfile ? (
|
{editingProfile ? (
|
||||||
@@ -1585,34 +1480,6 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const profileModalStyles = StyleSheet.create((theme) => ({
|
|
||||||
body: {
|
|
||||||
gap: theme.spacing[4],
|
|
||||||
paddingBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
fieldGroup: {
|
|
||||||
gap: theme.spacing[1.5],
|
|
||||||
},
|
|
||||||
fieldLabel: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
fieldHint: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
marginTop: theme.spacing[2],
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
identityEditButton: {
|
identityEditButton: {
|
||||||
padding: theme.spacing[1],
|
padding: theme.spacing[1],
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { TerminalProfileEditModal, type ProfileDraft } from "./terminal-profile-edit-modal";
|
||||||
|
|
||||||
|
const { theme } = vi.hoisted(() => ({
|
||||||
|
theme: {
|
||||||
|
spacing: { 2: 8, 3: 12, 4: 16 },
|
||||||
|
fontSize: { sm: 13, base: 15, xs: 11 },
|
||||||
|
fontWeight: { medium: 500 },
|
||||||
|
borderRadius: { lg: 8 },
|
||||||
|
colors: {
|
||||||
|
surface2: "#222",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
border: "#555",
|
||||||
|
palette: { red: { 300: "#f87171" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const inputPropsByTestID = vi.hoisted(() => ({
|
||||||
|
map: new Map<string, { onChangeText?: (next: string) => void; onSubmitEditing?: () => void }>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/adaptive-modal-sheet", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
const AdaptiveModalSheet = ({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) => {
|
||||||
|
if (!visible) return null;
|
||||||
|
return ReactModule.createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title },
|
||||||
|
ReactModule.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
"data-testid": "adaptive-modal-sheet-close",
|
||||||
|
onClick: onClose,
|
||||||
|
},
|
||||||
|
"Close",
|
||||||
|
),
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const AdaptiveTextInput = ReactModule.forwardRef<HTMLInputElement, Record<string, unknown>>(
|
||||||
|
(props, ref) => {
|
||||||
|
const p = props as {
|
||||||
|
initialValue?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
editable?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
onChangeText?: (next: string) => void;
|
||||||
|
onSubmitEditing?: () => void;
|
||||||
|
};
|
||||||
|
if (p.testID) {
|
||||||
|
inputPropsByTestID.map.set(p.testID, {
|
||||||
|
onChangeText: p.onChangeText,
|
||||||
|
onSubmitEditing: p.onSubmitEditing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ReactModule.createElement("input", {
|
||||||
|
ref,
|
||||||
|
defaultValue: p.initialValue ?? p.defaultValue ?? "",
|
||||||
|
disabled: p.editable === false,
|
||||||
|
"data-testid": p.testID,
|
||||||
|
onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value),
|
||||||
|
onKeyDown: (e: { key: string; preventDefault: () => void }) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
p.onSubmitEditing?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { AdaptiveModalSheet, AdaptiveTextInput };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/button", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
return {
|
||||||
|
Button: ({
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
}) =>
|
||||||
|
ReactModule.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
"data-testid": testID,
|
||||||
|
disabled: disabled || undefined,
|
||||||
|
onClick: () => !disabled && onPress?.(),
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
vi.stubGlobal("window", dom.window);
|
||||||
|
vi.stubGlobal("document", dom.window.document);
|
||||||
|
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||||
|
vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement);
|
||||||
|
vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent);
|
||||||
|
vi.stubGlobal("Node", dom.window.Node);
|
||||||
|
vi.stubGlobal("navigator", dom.window.navigator);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RenderOptions {
|
||||||
|
visible?: boolean;
|
||||||
|
title?: string;
|
||||||
|
initialDraft?: ProfileDraft;
|
||||||
|
onClose?: () => void;
|
||||||
|
onSave?: (draft: ProfileDraft) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal(options: RenderOptions = {}): void {
|
||||||
|
const {
|
||||||
|
visible = true,
|
||||||
|
title = "Add terminal profile",
|
||||||
|
initialDraft = { name: "", command: "", args: "" },
|
||||||
|
onClose = vi.fn(),
|
||||||
|
onSave = vi.fn().mockResolvedValue(undefined),
|
||||||
|
} = options;
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<TerminalProfileEditModal
|
||||||
|
visible={visible}
|
||||||
|
title={title}
|
||||||
|
initialDraft={initialDraft}
|
||||||
|
onClose={onClose}
|
||||||
|
onSave={onSave}
|
||||||
|
testID="terminal-profile-edit-modal"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryInput(testID: string): HTMLInputElement | null {
|
||||||
|
return document.querySelector<HTMLInputElement>(`[data-testid="${testID}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function querySubmit(): HTMLButtonElement | null {
|
||||||
|
return document.querySelector<HTMLButtonElement>('[data-testid="terminal-profile-save-button"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryCancel(): HTMLButtonElement | null {
|
||||||
|
return document.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="terminal-profile-cancel-button"]',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryError(testID: string): HTMLElement | null {
|
||||||
|
return document.querySelector<HTMLElement>(`[data-testid="${testID}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(element: Element | null): void {
|
||||||
|
if (!element) throw new Error("Cannot click null element");
|
||||||
|
act(() => {
|
||||||
|
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeInto(testID: string, value: string): void {
|
||||||
|
act(() => {
|
||||||
|
const input = queryInput(testID);
|
||||||
|
if (!input) throw new Error(`Input ${testID} not found`);
|
||||||
|
input.value = value;
|
||||||
|
inputPropsByTestID.map.get(testID)?.onChangeText?.(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressEnter(testID: string): void {
|
||||||
|
act(() => {
|
||||||
|
inputPropsByTestID.map.get(testID)?.onSubmitEditing?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TerminalProfileEditModal", () => {
|
||||||
|
it("renders with the initial draft pre-filled", () => {
|
||||||
|
renderModal({
|
||||||
|
initialDraft: { name: "Claude Code", command: "claude", args: "--skip" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryInput("terminal-profile-name-input")?.value).toBe("Claude Code");
|
||||||
|
expect(queryInput("terminal-profile-command-input")?.value).toBe("claude");
|
||||||
|
expect(queryInput("terminal-profile-args-input")?.value).toBe("--skip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave with trimmed values and closes on success", async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onSave, onClose });
|
||||||
|
|
||||||
|
typeInto("terminal-profile-name-input", " Codex ");
|
||||||
|
typeInto("terminal-profile-command-input", " codex ");
|
||||||
|
typeInto("terminal-profile-args-input", " --flag ");
|
||||||
|
click(querySubmit());
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).toHaveBeenCalledWith({
|
||||||
|
name: "Codex",
|
||||||
|
command: "codex",
|
||||||
|
args: " --flag ",
|
||||||
|
});
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows inline validation errors for empty required fields", async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
renderModal({ onSave });
|
||||||
|
|
||||||
|
click(querySubmit());
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
expect(queryError("terminal-profile-name-field-error")?.textContent).toContain(
|
||||||
|
"settings.host.terminalProfiles.nameRequired",
|
||||||
|
);
|
||||||
|
expect(queryError("terminal-profile-command-field-error")?.textContent).toContain(
|
||||||
|
"settings.host.terminalProfiles.commandRequired",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears validation errors when the user types", async () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
click(querySubmit());
|
||||||
|
await flush();
|
||||||
|
expect(queryError("terminal-profile-name-field-error")).not.toBeNull();
|
||||||
|
|
||||||
|
typeInto("terminal-profile-name-input", "Codex");
|
||||||
|
await flush();
|
||||||
|
expect(queryError("terminal-profile-name-field-error")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables save and cancel while onSave is pending", async () => {
|
||||||
|
let resolve: () => void = () => {};
|
||||||
|
const onSave = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderModal({ onSave });
|
||||||
|
|
||||||
|
typeInto("terminal-profile-name-input", "Codex");
|
||||||
|
typeInto("terminal-profile-command-input", "codex");
|
||||||
|
click(querySubmit());
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(querySubmit()?.disabled).toBe(true);
|
||||||
|
expect(queryCancel()?.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the modal open with an error when onSave rejects", async () => {
|
||||||
|
const onSave = vi.fn().mockRejectedValue(new Error("Server said no"));
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onSave, onClose });
|
||||||
|
|
||||||
|
typeInto("terminal-profile-name-input", "Codex");
|
||||||
|
typeInto("terminal-profile-command-input", "codex");
|
||||||
|
click(querySubmit());
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
document.querySelector<HTMLElement>('[data-testid="terminal-profile-submit-error"]')
|
||||||
|
?.textContent,
|
||||||
|
).toContain("Server said no");
|
||||||
|
expect(querySubmit()?.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when the cancel button is clicked", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
renderModal({ onClose, onSave });
|
||||||
|
|
||||||
|
click(queryCancel());
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the form when Enter is pressed in the args field", async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
renderModal({ onSave });
|
||||||
|
|
||||||
|
typeInto("terminal-profile-name-input", "Codex");
|
||||||
|
typeInto("terminal-profile-command-input", "codex");
|
||||||
|
pressEnter("terminal-profile-args-input");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).toHaveBeenCalledWith({
|
||||||
|
name: "Codex",
|
||||||
|
command: "codex",
|
||||||
|
args: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Text, TextInput, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FormField, FormTextInput } from "@/components/ui/form-field";
|
||||||
|
|
||||||
|
export interface ProfileDraft {
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
args: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldErrors {
|
||||||
|
name?: string;
|
||||||
|
command?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalProfileEditModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
initialDraft: ProfileDraft;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (draft: ProfileDraft) => Promise<void>;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalProfileEditModal({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
initialDraft,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
testID,
|
||||||
|
}: TerminalProfileEditModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [name, setName] = useState(initialDraft.name);
|
||||||
|
const [command, setCommand] = useState(initialDraft.command);
|
||||||
|
const [args, setArgs] = useState(initialDraft.args);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const nameInputRef = useRef<TextInput>(null);
|
||||||
|
const commandInputRef = useRef<TextInput>(null);
|
||||||
|
const argsInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const handleNameChange = useCallback((value: string) => {
|
||||||
|
setName(value);
|
||||||
|
setFieldErrors((current) => ({ ...current, name: undefined }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCommandChange = useCallback((value: string) => {
|
||||||
|
setCommand(value);
|
||||||
|
setFieldErrors((current) => ({ ...current, command: undefined }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleArgsChange = useCallback((value: string) => {
|
||||||
|
setArgs(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sheetHeader = useMemo<SheetHeader>(() => ({ title }), [title]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setIsPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setName(initialDraft.name);
|
||||||
|
setCommand(initialDraft.command);
|
||||||
|
setArgs(initialDraft.args);
|
||||||
|
setFieldErrors({});
|
||||||
|
setSubmitError(null);
|
||||||
|
setIsPending(false);
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
nameInputRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [visible, initialDraft.name, initialDraft.command, initialDraft.args]);
|
||||||
|
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const errors: FieldErrors = {};
|
||||||
|
if (name.trim().length === 0) {
|
||||||
|
errors.name = t("settings.host.terminalProfiles.nameRequired");
|
||||||
|
}
|
||||||
|
if (command.trim().length === 0) {
|
||||||
|
errors.command = t("settings.host.terminalProfiles.commandRequired");
|
||||||
|
}
|
||||||
|
setFieldErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}, [command, name, t]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (isPending) return;
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
if (!validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPending(true);
|
||||||
|
try {
|
||||||
|
await onSave({ name: name.trim(), command: command.trim(), args });
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError(error instanceof Error ? error.message : t("common.errors.unableToSave"));
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
|
}
|
||||||
|
}, [args, command, isPending, name, onClose, onSave, t, validate]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (isPending) return;
|
||||||
|
onClose();
|
||||||
|
}, [isPending, onClose]);
|
||||||
|
|
||||||
|
const handleNameSubmit = useCallback(() => {
|
||||||
|
commandInputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCommandSubmit = useCallback(() => {
|
||||||
|
argsInputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleArgsSubmit = useCallback(() => {
|
||||||
|
void handleSave();
|
||||||
|
}, [handleSave]);
|
||||||
|
|
||||||
|
const nameError = fieldErrors.name;
|
||||||
|
const commandError = fieldErrors.command;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
visible={visible}
|
||||||
|
header={sheetHeader}
|
||||||
|
onClose={handleCancel}
|
||||||
|
testID={testID}
|
||||||
|
desktopMaxWidth={480}
|
||||||
|
>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<FormField
|
||||||
|
label={t("settings.host.terminalProfiles.nameLabel")}
|
||||||
|
error={nameError}
|
||||||
|
testID="terminal-profile-name-field"
|
||||||
|
>
|
||||||
|
<FormTextInput
|
||||||
|
ref={nameInputRef}
|
||||||
|
initialValue={initialDraft.name}
|
||||||
|
resetKey={visible ? "open" : "closed"}
|
||||||
|
onChangeText={handleNameChange}
|
||||||
|
placeholder={t("settings.host.terminalProfiles.namePlaceholder")}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isPending}
|
||||||
|
returnKeyType="next"
|
||||||
|
onSubmitEditing={handleNameSubmit}
|
||||||
|
nativeID="terminal-profile-name-input"
|
||||||
|
accessibilityLabel={t("settings.host.terminalProfiles.nameLabel")}
|
||||||
|
testID="terminal-profile-name-input"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label={t("settings.host.terminalProfiles.commandLabel")}
|
||||||
|
error={commandError}
|
||||||
|
testID="terminal-profile-command-field"
|
||||||
|
>
|
||||||
|
<FormTextInput
|
||||||
|
ref={commandInputRef}
|
||||||
|
initialValue={initialDraft.command}
|
||||||
|
resetKey={visible ? "open" : "closed"}
|
||||||
|
onChangeText={handleCommandChange}
|
||||||
|
placeholder={t("settings.host.terminalProfiles.commandPlaceholder")}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isPending}
|
||||||
|
returnKeyType="next"
|
||||||
|
onSubmitEditing={handleCommandSubmit}
|
||||||
|
nativeID="terminal-profile-command-input"
|
||||||
|
accessibilityLabel={t("settings.host.terminalProfiles.commandLabel")}
|
||||||
|
testID="terminal-profile-command-input"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label={t("settings.host.terminalProfiles.argsLabel")}
|
||||||
|
hint={t("settings.host.terminalProfiles.argsHint")}
|
||||||
|
testID="terminal-profile-args-field"
|
||||||
|
>
|
||||||
|
<FormTextInput
|
||||||
|
ref={argsInputRef}
|
||||||
|
initialValue={initialDraft.args}
|
||||||
|
resetKey={visible ? "open" : "closed"}
|
||||||
|
onChangeText={handleArgsChange}
|
||||||
|
placeholder={t("settings.host.terminalProfiles.argsPlaceholder")}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isPending}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleArgsSubmit}
|
||||||
|
nativeID="terminal-profile-args-input"
|
||||||
|
accessibilityLabel={t("settings.host.terminalProfiles.argsLabel")}
|
||||||
|
testID="terminal-profile-args-input"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<Text style={styles.submitError} testID="terminal-profile-submit-error">
|
||||||
|
{submitError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={isPending}
|
||||||
|
testID="terminal-profile-cancel-button"
|
||||||
|
>
|
||||||
|
{t("common.actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isPending}
|
||||||
|
testID="terminal-profile-save-button"
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? t("settings.host.terminalProfiles.saving")
|
||||||
|
: t("settings.host.terminalProfiles.save")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
body: {
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
submitError: {
|
||||||
|
color: theme.colors.palette.red[300],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user