Remove custom providers from settings (#1951)

* feat(providers): remove custom providers from settings

Add a destructive removal flow so mistaken custom providers can be deleted from config.json instead of only disabled.

* test(app): cover provider removal with e2e

Move provider removal coverage out of mocked component tests and into the real Settings flow.

* fix(providers): keep removal live after config updates
This commit is contained in:
Mohamed Boudra
2026-07-16 16:11:51 +02:00
committed by GitHub
parent 6aba0370ae
commit e528a0db06
23 changed files with 920 additions and 35 deletions
+11
View File
@@ -1909,6 +1909,17 @@ export const ar: TranslationResources = {
loading: "تحميل...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "غير قادر على تحديث الموفر",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "عاجز",
loading: "تحميل",
+11
View File
@@ -1918,6 +1918,17 @@ export const en = {
loading: "Loading...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "Unable to update provider",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Disabled",
loading: "Loading",
+11
View File
@@ -1956,6 +1956,17 @@ export const es: TranslationResources = {
loading: "Cargando...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "No se puede actualizar el proveedor",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Desactivado",
loading: "Cargando",
+11
View File
@@ -1959,6 +1959,17 @@ export const fr: TranslationResources = {
loading: "Chargement...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "Impossible de mettre à jour le fournisseur",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Désactivé",
loading: "Chargement",
+11
View File
@@ -1931,6 +1931,17 @@ export const ja: TranslationResources = {
loading: "読み込み中...",
addErrorTitle: "プロバイダーを追加できません",
updateErrorTitle: "プロバイダーを更新できません",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "無効",
loading: "読み込み中",
+11
View File
@@ -1941,6 +1941,17 @@ export const ptBR: TranslationResources = {
loading: "Carregando...",
addErrorTitle: "Não foi possível adicionar provedor",
updateErrorTitle: "Não foi possível atualizar provedor",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Desativado",
loading: "Carregando",
+11
View File
@@ -1946,6 +1946,17 @@ export const ru: TranslationResources = {
loading: "Загрузка...",
addErrorTitle: "Unable to add provider",
updateErrorTitle: "Невозможно обновить провайдера",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "Неполноценный",
loading: "Загрузка",
+11
View File
@@ -1886,6 +1886,17 @@ export const zhCN: TranslationResources = {
loading: "正在加载...",
addErrorTitle: "无法添加 Provider",
updateErrorTitle: "无法更新 Provider",
actions: {
menu: "{{name}} actions",
remove: "Remove provider",
removing: "Removing...",
},
remove: {
confirmTitle: "Remove {{name}}?",
confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.",
confirm: "Remove",
errorTitle: "Unable to remove provider",
},
statuses: {
disabled: "已禁用",
loading: "正在加载",
@@ -97,25 +97,39 @@ vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return {
ChevronRight: icon("ChevronRight"),
MoreHorizontal: icon("MoreHorizontal"),
Trash2: icon("Trash2"),
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string | number>) => {
if (key === "settings.providers.providerDetails") return `${values?.name} provider details`;
if (key === "settings.providers.enableProvider") return `Enable ${values?.name}`;
if (key === "settings.providers.statuses.disabled") return "Disabled";
if (key === "settings.providers.statuses.available") return "Available";
if (key === "settings.providers.statuses.loading") return "Loading";
if (key === "settings.providers.statuses.error") return "Error";
if (key === "settings.providers.statuses.notInstalled") return "Not installed";
if (key === "settings.providers.models.one") return "1 model";
if (key === "settings.providers.models.many") return `${values?.count} models`;
if (key === "settings.providers.addErrorTitle") return "Unable to add provider";
if (key === "settings.providers.updateErrorTitle") return "Unable to update provider";
return key;
},
t: (key: string, values?: Record<string, string | number>) =>
(
({
"settings.providers.providerDetails": "{{name}} provider details",
"settings.providers.enableProvider": "Enable {{name}}",
"settings.providers.statuses.disabled": "Disabled",
"settings.providers.statuses.available": "Available",
"settings.providers.statuses.loading": "Loading",
"settings.providers.statuses.error": "Error",
"settings.providers.statuses.notInstalled": "Not installed",
"settings.providers.models.one": "1 model",
"settings.providers.models.many": "{{count}} models",
"settings.providers.addErrorTitle": "Unable to add provider",
"settings.providers.updateErrorTitle": "Unable to update provider",
"settings.providers.actions.menu": "{{name}} actions",
"settings.providers.actions.remove": "Remove provider",
"settings.providers.actions.removing": "Removing...",
"settings.providers.remove.confirmTitle": "Remove {{name}}?",
"settings.providers.remove.confirmMessage":
"This deletes the provider entry from config.json. It cannot be undone.",
"settings.providers.remove.confirm": "Remove",
"settings.providers.remove.errorTitle": "Unable to remove provider",
})[key] ?? key
)
.replaceAll("{{name}}", String(values?.name ?? ""))
.replaceAll("{{count}}", String(values?.count ?? "")),
}),
}));
@@ -151,6 +165,68 @@ vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
DropdownMenuTrigger: ({
children,
onPressIn,
accessibilityRole,
accessibilityLabel,
testID,
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
onPressIn?: (event: { stopPropagation: () => void }) => void;
accessibilityRole?: string;
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
role: accessibilityRole,
"aria-label": accessibilityLabel,
"data-testid": testID,
onMouseDown: (event: React.MouseEvent) => onPressIn?.(event),
onClick: (event: React.MouseEvent) => event.stopPropagation(),
},
typeof children === "function"
? children({ pressed: false, hovered: false, open: false })
: children,
),
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
DropdownMenuItem: ({
children,
onSelect,
status,
pendingLabel,
testID,
}: {
children?: React.ReactNode;
onSelect?: () => void;
status?: "idle" | "pending" | "success";
pendingLabel?: string;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"data-testid": testID,
disabled: status === "pending" || status === "success",
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
onSelect?.();
},
},
status === "pending" ? pendingLabel : children,
),
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: (provider: string) => () =>
React.createElement("span", { "data-icon": `provider-${provider}` }),
@@ -190,6 +266,14 @@ vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: () => false,
}));
vi.mock("@/utils/confirm-dialog", () => ({
confirmDialog: vi.fn(async () => true),
}));
import { ProvidersSection } from "./providers-section";
const claudeEntry: ProviderSnapshotEntry = {
@@ -1,10 +1,18 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import {
Alert,
Pressable,
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
@@ -16,9 +24,16 @@ import { ProviderCatalogList } from "@/components/provider-catalog-list";
import { getProviderIcon } from "@/components/provider-icons";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SettingsSection } from "@/screens/settings/settings-section";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { ChevronRight } from "lucide-react-native";
import { confirmDialog } from "@/utils/confirm-dialog";
import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native";
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
@@ -64,9 +79,89 @@ interface ProviderRowProps {
entry: ProviderEntry;
enabled: boolean;
isToggling: boolean;
isRemoving: boolean;
canRemove: boolean;
isFirst: boolean;
onPress: (providerId: string) => void;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
onRemove: (providerId: string, providerLabel: string) => void;
}
function stopPressInPropagation(event: GestureResponderEvent) {
event.stopPropagation();
}
interface ProviderActionsMenuProps {
providerId: string;
providerLabel: string;
isRemoving: boolean;
iconSize: number;
foregroundColor: string;
foregroundMutedColor: string;
dangerColor: string;
onRemove: (providerId: string, providerLabel: string) => void;
}
function ProviderActionsMenu({
providerId,
providerLabel,
isRemoving,
iconSize,
foregroundColor,
foregroundMutedColor,
dangerColor,
onRemove,
}: ProviderActionsMenuProps) {
const { t } = useTranslation();
const handleRemove = useCallback(() => {
onRemove(providerId, providerLabel);
}, [onRemove, providerId, providerLabel]);
const triggerStyle = useCallback(
({
pressed,
hovered,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) => [
styles.menuButton,
(hovered || open) && styles.menuButtonHovered,
pressed && styles.menuButtonPressed,
],
[],
);
const trashLeading = useMemo(() => <Trash2 size={16} color={dangerColor} />, [dangerColor]);
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={isRemoving}
hitSlop={8}
onPressIn={stopPressInPropagation}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.actions.menu", { name: providerLabel })}
testID={`provider-actions-${providerId}`}
>
{({ hovered, open }) => (
<MoreHorizontal
size={iconSize}
color={hovered || open ? foregroundColor : foregroundMutedColor}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<DropdownMenuItem
destructive
leading={trashLeading}
onSelect={handleRemove}
status={isRemoving ? "pending" : "idle"}
pendingLabel={t("settings.providers.actions.removing")}
testID={`provider-remove-${providerId}`}
>
{t("settings.providers.actions.remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProviderRow({
@@ -74,9 +169,12 @@ function ProviderRow({
entry,
enabled,
isToggling,
isRemoving,
canRemove,
isFirst,
onPress,
onToggleEnabled,
onRemove,
}: ProviderRowProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
@@ -141,12 +239,26 @@ function ProviderRow({
) : null}
</View>
</View>
<Switch
value={enabled}
onValueChange={handleToggleValueChange}
disabled={isToggling}
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
/>
<View style={styles.trailingControls}>
<Switch
value={enabled}
onValueChange={handleToggleValueChange}
disabled={isToggling || isRemoving}
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
/>
{canRemove ? (
<ProviderActionsMenu
providerId={def.id}
providerLabel={def.label}
isRemoving={isRemoving}
iconSize={theme.iconSize.sm}
foregroundColor={theme.colors.foreground}
foregroundMutedColor={theme.colors.foregroundMuted}
dangerColor={theme.colors.statusDanger}
onRemove={onRemove}
/>
) : null}
</View>
</>
)}
</Pressable>
@@ -203,10 +315,13 @@ export interface ProvidersSectionProps {
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
const removingProviderIdRef = useRef<string | null>(null);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
@@ -236,6 +351,38 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
[patchConfig, t],
);
const handleRemoveProvider = useCallback(
async (providerId: string, providerLabel: string) => {
if (removingProviderIdRef.current) return;
removingProviderIdRef.current = providerId;
setRemovingProviderId(providerId);
try {
const confirmed = await confirmDialog({
title: t("settings.providers.remove.confirmTitle", { name: providerLabel }),
message: t("settings.providers.remove.confirmMessage"),
confirmLabel: t("settings.providers.remove.confirm"),
destructive: true,
});
if (!confirmed) {
return;
}
await patchConfig({ removeProviders: [providerId] });
} catch (error) {
Alert.alert(
t("settings.providers.remove.errorTitle"),
error instanceof Error ? error.message : String(error),
);
} finally {
if (removingProviderIdRef.current === providerId) {
removingProviderIdRef.current = null;
}
setRemovingProviderId((current) => (current === providerId ? null : current));
}
},
[patchConfig, t],
);
const handleInstall = useCallback(
async (entry: AcpProviderCatalogItem) => {
if (installingProviderId) return;
@@ -284,9 +431,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
entry={entry}
enabled={entry.enabled ?? true}
isToggling={pendingProviderId === def.id}
isRemoving={removingProviderId === def.id}
canRemove={supportsProviderRemoval && entry.source === "custom"}
isFirst={index === 0}
onPress={handleOpenProviderSettings}
onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider}
/>
);
})}
@@ -374,6 +524,24 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
trailingControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
menuButton: {
width: 32,
height: 32,
borderRadius: theme.borderRadius.lg,
alignItems: "center",
justifyContent: "center",
},
menuButtonHovered: {
backgroundColor: theme.colors.surface2,
},
menuButtonPressed: {
backgroundColor: theme.colors.surface3,
},
}));
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];