import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { StatusBadge } from "@/components/ui/status-badge";
import { Switch } from "@/components/ui/switch";
import { useFetchQuery } from "@/data/query";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { resolvePluginPageState } from "@/screens/settings/plugins-page-state";
import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const;
type PluginRowAction = "reload" | "enable" | "disable" | "remove";
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function pluginRowAction(action: string | undefined): PluginRowAction | undefined {
if (action === "reload" || action === "enable" || action === "disable" || action === "remove") {
return action;
}
return undefined;
}
function PluginRow({
plugin,
clientError,
pending,
pendingAction,
onAction,
onOpenLogs,
supportsLogs,
}: {
plugin: PluginListItem;
clientError?: string;
pending: boolean;
pendingAction?: PluginRowAction;
onAction(action: PluginRowAction, plugin: PluginListItem): void;
onOpenLogs(pluginId: string): void;
supportsLogs: boolean;
}) {
const { t } = useTranslation();
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
const toggle = useCallback(
() => onAction(plugin.enabled ? "disable" : "enable", plugin),
[onAction, plugin],
);
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]);
const status = clientError ? "failed" : plugin.status;
let badgeVariant: "success" | "error" | "muted" = "muted";
if (status === "running") badgeVariant = "success";
else if (status === "failed") badgeVariant = "error";
const statusLabel = t(`settings.plugins.status.${status}`);
let toggleLabel = t(
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
);
if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling");
else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling");
return (
{plugin.id}
{plugin.path}
{clientError || plugin.error ? (
{clientError ?? plugin.error}
) : null}
{supportsLogs ? (
) : null}
);
}
function PluginLogsSheet({
client,
pluginId,
serverId,
onClose,
}: {
client: NonNullable>;
pluginId: string;
serverId: string;
onClose(): void;
}) {
const { t } = useTranslation();
const logs = useFetchQuery({
queryKey: ["plugin-logs", serverId, pluginId],
queryFn: () => client.getPluginLogs(pluginId),
dataShape: "list",
staleTimeMs: 0,
});
const refresh = useCallback(() => {
void logs.refetch();
}, [logs]);
const header = useMemo(
() => ({
title: t("settings.plugins.logs.title", { id: pluginId }),
actions: (
),
}),
[logs.isFetching, pluginId, refresh, t],
);
let content = {t("settings.plugins.logs.loading")};
if (logs.isError) {
content = (
);
} else if (!logs.isLoading) {
content = logs.data?.length ? (
{logs.data.map((entry: PluginLogEntry) => (
{entry.timestamp} {entry.stream}
{entry.message}
))}
) : (
{t("settings.plugins.logs.empty")}
);
}
return (
{content}
);
}
export function HostPluginsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "pluginManagement");
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
const logsSupported = useHostFeature(serverId, "pluginLogs");
const { config, patchConfig } = useDaemonConfig(serverId);
const refreshQueue = useRef(Promise.resolve());
useInstalledPlugins();
const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]);
const [directory, setDirectory] = useState("");
const [pluginId, setPluginId] = useState("");
const [directoryResetKey, setDirectoryResetKey] = useState(0);
const [pluginIdResetKey, setPluginIdResetKey] = useState(0);
const [logsPluginId, setLogsPluginId] = useState(null);
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
null,
);
const plugins = useFetchQuery({
queryKey,
queryFn: async () => {
if (!client) throw new Error("Plugin host is offline");
return client.listPlugins();
},
enabled: Boolean(client && connected && supported),
dataShape: "list",
staleTimeMs: 1_000,
});
const refetchPlugins = plugins.refetch;
const refresh = useCallback(async () => {
if (!client) return;
const refetch = async () => {
await refetchPlugins();
};
refreshQueue.current = refreshQueue.current.then(refetch, refetch);
await refreshQueue.current;
}, [client, refetchPlugins]);
useEffect(() => {
if (!client) return;
return client.on("status", (message) => {
if (message.payload.status === "plugin_catalog_changed") void refresh();
});
}, [client, refresh]);
const mutation = useMutation({
mutationFn: async (operation: {
action: string;
pluginId?: string;
run: () => Promise;
}) => operation.run(),
onSuccess: async (message) => {
setFeedback({ kind: "success", message });
await refresh();
},
onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }),
});
const install = useCallback(() => {
const path = directory.trim();
if (!client || !path) return;
setFeedback(null);
mutation.mutate({
action: "install",
run: async () => {
const installed = await client.installDirectoryPlugin(path, pluginId.trim() || undefined);
setDirectory("");
setPluginId("");
setDirectoryResetKey((key) => key + 1);
setPluginIdResetKey((key) => key + 1);
return t("settings.plugins.feedback.installed", { id: installed.id });
},
});
}, [client, directory, mutation, pluginId, t]);
const prefillManifestId = useCallback(() => {
const path = directory.trim();
if (!client || !path || pluginId.trim()) return;
void client
.inspectDirectoryPlugin(path)
.then(({ id }) => {
setPluginId(id);
setPluginIdResetKey((key) => key + 1);
return undefined;
})
.catch(() => undefined);
}, [client, directory, pluginId]);
const action = useCallback(
(name: PluginRowAction, plugin: PluginListItem) => {
if (!client) return;
const run = async () => {
if (name === "remove") {
const confirmed = await confirmDialog({
title: t("settings.plugins.removeConfirmTitle", { id: plugin.id }),
message: t("settings.plugins.removeConfirmMessage"),
confirmLabel: t("settings.plugins.actions.remove"),
destructive: true,
});
if (!confirmed) return t("settings.plugins.feedback.kept", { id: plugin.id });
await client.removePlugin(plugin.id);
} else {
await client[`${name}Plugin`](plugin.id);
}
return t(`settings.plugins.feedback.${name}`, { id: plugin.id });
};
setFeedback(null);
mutation.mutate({ action: name, pluginId: plugin.id, run });
},
[client, mutation, t],
);
const toggleGlobal = useCallback(
(enabled: boolean) => {
setFeedback(null);
mutation.mutate({
action: enabled ? "global-enable" : "global-disable",
run: async () => {
await patchConfig({ pluginsEnabled: enabled });
return enabled
? t("settings.plugins.feedback.globalEnabled")
: t("settings.plugins.feedback.globalDisabled");
},
});
},
[mutation, patchConfig, t],
);
const pageState = resolvePluginPageState({
connected,
supported,
loading: plugins.isLoading,
error: plugins.isError,
pluginCount: plugins.data?.length ?? 0,
});
const retry = useCallback(() => {
void refetchPlugins();
}, [refetchPlugins]);
const closeLogs = useCallback(() => setLogsPluginId(null), []);
if (pageState === "offline") {
return (
);
}
if (pageState === "unsupported") {
return ;
}
let catalogContent = ;
if (pageState === "error") {
catalogContent = (
);
} else if (pageState !== "loading") {
catalogContent = (
{plugins.data?.length ? (
plugins.data.map((plugin) => {
const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id);
const pending = mutation.isPending && mutation.variables?.pluginId === plugin.id;
return (
);
})
) : (
{t("settings.plugins.states.empty")}
)}
);
}
return (
{t("settings.plugins.globalTitle")}
{t("settings.plugins.globalHint")}
{feedback ? (
) : null}
{catalogContent}
{client && logsPluginId ? (
) : null}
);
}
const styles = StyleSheet.create((theme) => ({
install: { padding: theme.spacing[4], gap: theme.spacing[3] },
pluginRow: {
padding: theme.spacing[4],
gap: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
pluginTitle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] },
error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.xs },
empty: { padding: theme.spacing[4], alignItems: "center" },
logsState: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
paddingVertical: theme.spacing[6],
},
logList: { gap: theme.spacing[2] },
logEntry: {
padding: theme.spacing[3],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
},
logStdout: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.xs,
},
logStderr: {
color: theme.colors.statusDanger,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.xs,
},
logMessage: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));