Add managed local plugin lifecycle (#3222)
* feat(plugins): add local plugin surfaces and RPCs Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches. * feat(plugins): add Linear attachment source * fix(nix): refresh npm dependency hash * test(server): exclude internal sessions from Hub counts * test(server): distinguish Hub execution sessions * feat(plugins): make local plugins explicitly opt-in Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory. * refactor(plugins): establish host boundaries * fix(nix): refresh plugin dependency hash * fix(plugins): complete packaging and startup rollback * feat(plugins): add managed local lifecycle Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership. * fix(nix): refresh plugin dependency hash
This commit is contained in:
@@ -127,6 +127,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { PluginCatalogSync } from "@/plugins";
|
||||
import {
|
||||
ensureOsNotificationPermission,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
@@ -270,6 +271,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
return (
|
||||
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||
<LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} />
|
||||
<PluginCatalogSync serverId={daemon.serverId} client={client} />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ function KnownHostRoute() {
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
|
||||
</ThemedStack>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PluginSurfaceScreen } from "@/plugins";
|
||||
|
||||
export default function PluginSurfaceRoute() {
|
||||
return <PluginSurfaceScreen />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getAgentAttachmentPillContent } from "./attachment-pill-content";
|
||||
|
||||
describe("agent attachment pill content", () => {
|
||||
it("presents external resources with their provider identity", () => {
|
||||
const content = getAgentAttachmentPillContent(
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "ENG-123 Plugin attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments",
|
||||
externalResource: {
|
||||
provider: "linear",
|
||||
providerLabel: "Linear issue",
|
||||
resourceType: "issue",
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
},
|
||||
},
|
||||
i18n.t,
|
||||
);
|
||||
|
||||
expect(content.title).toBe("Plugin attachments");
|
||||
expect(content.subtitle).toBe("Linear issue ENG-123");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import React from "react";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
if (attachment.externalResource) {
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.externalResource.title,
|
||||
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ForgeSearchItem,
|
||||
UploadedFileAttachment,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PluginResourceComposerAttachment } from "@/plugins/attachments";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
| WorkspaceFileComposerAttachment
|
||||
| PluginResourceComposerAttachment
|
||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||
|
||||
@@ -75,6 +75,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
import { PluginSidebarItems } from "@/plugins";
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
@@ -681,6 +682,7 @@ function MobileSidebar({
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<Pressable
|
||||
@@ -899,6 +901,7 @@ function DesktopSidebar({
|
||||
testID="sidebar-schedules"
|
||||
variant="compact"
|
||||
/>
|
||||
<PluginSidebarItems />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -863,6 +863,35 @@ describe("openComposerAttachment", () => {
|
||||
});
|
||||
expect(externalUrlCalls).toEqual([issueItem.url]);
|
||||
});
|
||||
|
||||
it("opens plugin resource URLs through the external url opener", () => {
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: {
|
||||
kind: "plugin_resource",
|
||||
pluginId: "linear",
|
||||
sourceId: "issues",
|
||||
sourceTitle: "Linear issue",
|
||||
sourceIcon: "CircleDot",
|
||||
item: {
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments",
|
||||
resourceType: "issue",
|
||||
},
|
||||
},
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleGithubAttachment", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildLegacyGitHubAttachmentFromSearchItem,
|
||||
} from "@/utils/review-attachments";
|
||||
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
|
||||
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments";
|
||||
|
||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||
|
||||
@@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attachment.kind === "plugin_resource") {
|
||||
agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
||||
images.push(attachment.attachment.screenshot);
|
||||
|
||||
@@ -126,6 +126,7 @@ import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||
import { readClipboardImage } from "./clipboard-image";
|
||||
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
|
||||
import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins";
|
||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import {
|
||||
appendWorkspaceFileAttachment,
|
||||
@@ -441,6 +442,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
onRemove,
|
||||
});
|
||||
}
|
||||
if (attachment.kind === "plugin_resource") {
|
||||
return (
|
||||
<PluginResourceAttachmentPill
|
||||
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
|
||||
attachment={attachment}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onOpen={onOpen}
|
||||
onRemove={onRemove}
|
||||
openLabel={labels.openGithub}
|
||||
removeLabel={labels.removeGithub}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GithubAttachmentPill
|
||||
key={`${attachment.item.kind}:${attachment.item.number}`}
|
||||
@@ -1162,6 +1177,14 @@ export function Composer({
|
||||
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
|
||||
const attachButtonRef = useRef<View | null>(null);
|
||||
const messageInputRef = useRef<MessageInputRef>(null);
|
||||
const pluginAttachments = usePluginAttachmentPicker({
|
||||
serverId,
|
||||
client,
|
||||
connected: isConnected,
|
||||
attachments,
|
||||
onChangeAttachments: setSelectedAttachments,
|
||||
anchorRef: attachButtonRef,
|
||||
});
|
||||
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
|
||||
const keyboardHandlerIdRef = useRef(
|
||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||
@@ -1961,6 +1984,7 @@ export function Composer({
|
||||
setIsGithubPickerOpen(true);
|
||||
},
|
||||
},
|
||||
...pluginAttachments.menuItems,
|
||||
{
|
||||
id: "file",
|
||||
label: t("composer.attachments.addFile"),
|
||||
@@ -1971,7 +1995,14 @@ export function Composer({
|
||||
},
|
||||
);
|
||||
return items;
|
||||
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
|
||||
}, [
|
||||
forgePresentation,
|
||||
handlePasteImage,
|
||||
handlePickFile,
|
||||
handlePickImage,
|
||||
pluginAttachments.menuItems,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
(item: ForgeSearchItem) => {
|
||||
@@ -2231,6 +2262,7 @@ export function Composer({
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
{pluginAttachments.picker}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useQueries,
|
||||
useQuery,
|
||||
type QueryKey,
|
||||
type QueryClient,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
@@ -47,8 +48,11 @@ export function useFetchQuery<
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
|
||||
return useQuery(fetchQueryOptions(input));
|
||||
>(
|
||||
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> {
|
||||
return useQuery(fetchQueryOptions(input), queryClient);
|
||||
}
|
||||
|
||||
export function useFetchQueries<TData>(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ar: TranslationResources = {
|
||||
common: {
|
||||
@@ -1785,8 +1786,10 @@ export const ar: TranslationResources = {
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
terminals: "Terminals",
|
||||
plugins: "Plugins",
|
||||
host: "نظرة عامة",
|
||||
},
|
||||
plugins: pluginSettings.ar,
|
||||
metadataGeneration: {
|
||||
title: "إنشاء البيانات الوصفية",
|
||||
description:
|
||||
|
||||
@@ -1796,8 +1796,56 @@ export const en = {
|
||||
providers: "Providers",
|
||||
usage: "Usage",
|
||||
terminals: "Terminals",
|
||||
plugins: "Plugins",
|
||||
host: "Overview",
|
||||
},
|
||||
plugins: {
|
||||
title: "Plugins",
|
||||
trustedTitle: "Plugins are trusted code",
|
||||
trustedDescription:
|
||||
"They run on this host and inside the app without sandboxing. Install only code you trust.",
|
||||
globalTitle: "Enable plugins",
|
||||
globalHint: "Global switch for every configured plugin",
|
||||
directoryLabel: "Plugin directory",
|
||||
directoryPlaceholder: "/absolute/path/on/host",
|
||||
idLabel: "Plugin installation ID",
|
||||
idHint: "Leave blank to use paseo-plugin.json",
|
||||
idPlaceholder: "Manifest default",
|
||||
install: "Install directory",
|
||||
installing: "Installing…",
|
||||
status: { running: "running", disabled: "disabled", failed: "failed" },
|
||||
actions: {
|
||||
reload: "Reload",
|
||||
reloading: "Reloading…",
|
||||
enable: "Enable",
|
||||
enabling: "Enabling…",
|
||||
disable: "Disable",
|
||||
disabling: "Disabling…",
|
||||
remove: "Remove",
|
||||
removing: "Removing…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "Installed {{id}}",
|
||||
reload: "Reloaded {{id}}",
|
||||
enable: "Enabled {{id}}",
|
||||
disable: "Disabled {{id}}",
|
||||
remove: "Removed {{id}}",
|
||||
kept: "Kept {{id}}",
|
||||
globalEnabled: "Plugins enabled",
|
||||
globalDisabled: "Plugins disabled",
|
||||
},
|
||||
removeConfirmTitle: "Remove {{id}}?",
|
||||
removeConfirmMessage: "This removes the configuration. The source directory is not deleted.",
|
||||
states: {
|
||||
offlineTitle: "Plugin host is offline",
|
||||
offlineDescription: "Reconnect to this host to manage its plugins.",
|
||||
updateTitle: "Update this host to manage plugins",
|
||||
loading: "Loading plugins…",
|
||||
errorTitle: "Unable to load plugins",
|
||||
retry: "Retry",
|
||||
empty: "No plugins configured",
|
||||
},
|
||||
},
|
||||
metadataGeneration: {
|
||||
title: "Metadata generation",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const es: TranslationResources = {
|
||||
common: {
|
||||
@@ -1828,8 +1829,10 @@ export const es: TranslationResources = {
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminals",
|
||||
plugins: "Plugins",
|
||||
host: "Resumen",
|
||||
},
|
||||
plugins: pluginSettings.es,
|
||||
metadataGeneration: {
|
||||
title: "Generación de metadatos",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const fr: TranslationResources = {
|
||||
common: {
|
||||
@@ -1832,8 +1833,10 @@ export const fr: TranslationResources = {
|
||||
providers: "Fournisseurs",
|
||||
usage: "Utilisation",
|
||||
terminals: "Terminals",
|
||||
plugins: "Plugins",
|
||||
host: "Aperçu",
|
||||
},
|
||||
plugins: pluginSettings.fr,
|
||||
metadataGeneration: {
|
||||
title: "Génération de métadonnées",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ja: TranslationResources = {
|
||||
common: {
|
||||
@@ -1801,8 +1802,10 @@ export const ja: TranslationResources = {
|
||||
providers: "プロバイダー",
|
||||
usage: "使用状況",
|
||||
terminals: "ターミナル",
|
||||
plugins: "プラグイン",
|
||||
host: "概要",
|
||||
},
|
||||
plugins: pluginSettings.ja,
|
||||
metadataGeneration: {
|
||||
title: "メタデータ生成",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ko: TranslationResources = {
|
||||
common: {
|
||||
@@ -1795,8 +1796,10 @@ export const ko: TranslationResources = {
|
||||
providers: "프로바이더",
|
||||
usage: "사용량",
|
||||
terminals: "터미널",
|
||||
plugins: "플러그인",
|
||||
host: "개요",
|
||||
},
|
||||
plugins: pluginSettings.ko,
|
||||
metadataGeneration: {
|
||||
title: "메타데이터 생성",
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
const shared = {
|
||||
directoryPlaceholder: "/absolute/path/on/host",
|
||||
} as const;
|
||||
|
||||
export const pluginSettings = {
|
||||
ar: {
|
||||
...shared,
|
||||
title: "الإضافات",
|
||||
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
|
||||
trustedDescription:
|
||||
"تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.",
|
||||
globalTitle: "تفعيل الإضافات",
|
||||
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
|
||||
directoryLabel: "مجلد الإضافة",
|
||||
idLabel: "معرّف تثبيت الإضافة",
|
||||
idHint: "اتركه فارغًا لاستخدام paseo-plugin.json",
|
||||
idPlaceholder: "القيمة الافتراضية للبيان",
|
||||
install: "تثبيت المجلد",
|
||||
installing: "جارٍ التثبيت…",
|
||||
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
|
||||
actions: {
|
||||
reload: "إعادة تحميل",
|
||||
reloading: "جارٍ إعادة التحميل…",
|
||||
enable: "تفعيل",
|
||||
enabling: "جارٍ التفعيل…",
|
||||
disable: "تعطيل",
|
||||
disabling: "جارٍ التعطيل…",
|
||||
remove: "إزالة",
|
||||
removing: "جارٍ الإزالة…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "تم تثبيت {{id}}",
|
||||
reload: "تمت إعادة تحميل {{id}}",
|
||||
enable: "تم تفعيل {{id}}",
|
||||
disable: "تم تعطيل {{id}}",
|
||||
remove: "تمت إزالة {{id}}",
|
||||
kept: "تم الإبقاء على {{id}}",
|
||||
globalEnabled: "تم تفعيل الإضافات",
|
||||
globalDisabled: "تم تعطيل الإضافات",
|
||||
},
|
||||
removeConfirmTitle: "إزالة {{id}}؟",
|
||||
removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.",
|
||||
states: {
|
||||
offlineTitle: "مضيف الإضافات غير متصل",
|
||||
offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.",
|
||||
updateTitle: "حدّث هذا المضيف لإدارة الإضافات",
|
||||
loading: "جارٍ تحميل الإضافات…",
|
||||
errorTitle: "تعذّر تحميل الإضافات",
|
||||
retry: "إعادة المحاولة",
|
||||
empty: "لا توجد إضافات مضبوطة",
|
||||
},
|
||||
},
|
||||
es: {
|
||||
...shared,
|
||||
title: "Plugins",
|
||||
trustedTitle: "Los plugins son código de confianza",
|
||||
trustedDescription:
|
||||
"Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.",
|
||||
globalTitle: "Activar plugins",
|
||||
globalHint: "Interruptor global para todos los plugins configurados",
|
||||
directoryLabel: "Directorio del plugin",
|
||||
idLabel: "ID de instalación del plugin",
|
||||
idHint: "Déjalo vacío para usar paseo-plugin.json",
|
||||
idPlaceholder: "Valor del manifiesto",
|
||||
install: "Instalar directorio",
|
||||
installing: "Instalando…",
|
||||
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
|
||||
actions: {
|
||||
reload: "Recargar",
|
||||
reloading: "Recargando…",
|
||||
enable: "Activar",
|
||||
enabling: "Activando…",
|
||||
disable: "Desactivar",
|
||||
disabling: "Desactivando…",
|
||||
remove: "Eliminar",
|
||||
removing: "Eliminando…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} instalado",
|
||||
reload: "{{id}} recargado",
|
||||
enable: "{{id}} activado",
|
||||
disable: "{{id}} desactivado",
|
||||
remove: "{{id}} eliminado",
|
||||
kept: "Se conservó {{id}}",
|
||||
globalEnabled: "Plugins activados",
|
||||
globalDisabled: "Plugins desactivados",
|
||||
},
|
||||
removeConfirmTitle: "¿Eliminar {{id}}?",
|
||||
removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.",
|
||||
states: {
|
||||
offlineTitle: "El host de plugins está sin conexión",
|
||||
offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.",
|
||||
updateTitle: "Actualiza este host para gestionar plugins",
|
||||
loading: "Cargando plugins…",
|
||||
errorTitle: "No se pudieron cargar los plugins",
|
||||
retry: "Reintentar",
|
||||
empty: "No hay plugins configurados",
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
...shared,
|
||||
title: "Plugins",
|
||||
trustedTitle: "Les plugins sont du code de confiance",
|
||||
trustedDescription:
|
||||
"Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.",
|
||||
globalTitle: "Activer les plugins",
|
||||
globalHint: "Interrupteur global pour tous les plugins configurés",
|
||||
directoryLabel: "Dossier du plugin",
|
||||
idLabel: "ID d’installation du plugin",
|
||||
idHint: "Laissez vide pour utiliser paseo-plugin.json",
|
||||
idPlaceholder: "Valeur du manifeste",
|
||||
install: "Installer le dossier",
|
||||
installing: "Installation…",
|
||||
status: { running: "actif", disabled: "désactivé", failed: "échec" },
|
||||
actions: {
|
||||
reload: "Recharger",
|
||||
reloading: "Rechargement…",
|
||||
enable: "Activer",
|
||||
enabling: "Activation…",
|
||||
disable: "Désactiver",
|
||||
disabling: "Désactivation…",
|
||||
remove: "Supprimer",
|
||||
removing: "Suppression…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} installé",
|
||||
reload: "{{id}} rechargé",
|
||||
enable: "{{id}} activé",
|
||||
disable: "{{id}} désactivé",
|
||||
remove: "{{id}} supprimé",
|
||||
kept: "{{id}} conservé",
|
||||
globalEnabled: "Plugins activés",
|
||||
globalDisabled: "Plugins désactivés",
|
||||
},
|
||||
removeConfirmTitle: "Supprimer {{id}} ?",
|
||||
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
|
||||
states: {
|
||||
offlineTitle: "L’hôte des plugins est hors ligne",
|
||||
offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.",
|
||||
updateTitle: "Mettez cet hôte à jour pour gérer les plugins",
|
||||
loading: "Chargement des plugins…",
|
||||
errorTitle: "Impossible de charger les plugins",
|
||||
retry: "Réessayer",
|
||||
empty: "Aucun plugin configuré",
|
||||
},
|
||||
},
|
||||
ja: {
|
||||
...shared,
|
||||
title: "プラグイン",
|
||||
trustedTitle: "プラグインは信頼済みコードです",
|
||||
trustedDescription:
|
||||
"ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。",
|
||||
globalTitle: "プラグインを有効化",
|
||||
globalHint: "設定済みプラグインすべての共通スイッチ",
|
||||
directoryLabel: "プラグインディレクトリ",
|
||||
idLabel: "プラグインのインストール ID",
|
||||
idHint: "paseo-plugin.json を使う場合は空欄にします",
|
||||
idPlaceholder: "マニフェストの既定値",
|
||||
install: "ディレクトリをインストール",
|
||||
installing: "インストール中…",
|
||||
status: { running: "実行中", disabled: "無効", failed: "失敗" },
|
||||
actions: {
|
||||
reload: "再読み込み",
|
||||
reloading: "再読み込み中…",
|
||||
enable: "有効化",
|
||||
enabling: "有効化中…",
|
||||
disable: "無効化",
|
||||
disabling: "無効化中…",
|
||||
remove: "削除",
|
||||
removing: "削除中…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} をインストールしました",
|
||||
reload: "{{id}} を再読み込みしました",
|
||||
enable: "{{id}} を有効にしました",
|
||||
disable: "{{id}} を無効にしました",
|
||||
remove: "{{id}} を削除しました",
|
||||
kept: "{{id}} を保持しました",
|
||||
globalEnabled: "プラグインを有効にしました",
|
||||
globalDisabled: "プラグインを無効にしました",
|
||||
},
|
||||
removeConfirmTitle: "{{id}} を削除しますか?",
|
||||
removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。",
|
||||
states: {
|
||||
offlineTitle: "プラグインホストはオフラインです",
|
||||
offlineDescription: "プラグインを管理するにはホストに再接続してください。",
|
||||
updateTitle: "プラグインを管理するにはホストを更新してください",
|
||||
loading: "プラグインを読み込み中…",
|
||||
errorTitle: "プラグインを読み込めません",
|
||||
retry: "再試行",
|
||||
empty: "設定済みプラグインはありません",
|
||||
},
|
||||
},
|
||||
ko: {
|
||||
...shared,
|
||||
title: "플러그인",
|
||||
trustedTitle: "플러그인은 신뢰하는 코드입니다",
|
||||
trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.",
|
||||
globalTitle: "플러그인 활성화",
|
||||
globalHint: "설정된 모든 플러그인의 전역 스위치",
|
||||
directoryLabel: "플러그인 디렉터리",
|
||||
idLabel: "플러그인 설치 ID",
|
||||
idHint: "paseo-plugin.json을 사용하려면 비워 두세요",
|
||||
idPlaceholder: "매니페스트 기본값",
|
||||
install: "디렉터리 설치",
|
||||
installing: "설치 중…",
|
||||
status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
|
||||
actions: {
|
||||
reload: "다시 로드",
|
||||
reloading: "다시 로드 중…",
|
||||
enable: "활성화",
|
||||
enabling: "활성화 중…",
|
||||
disable: "비활성화",
|
||||
disabling: "비활성화 중…",
|
||||
remove: "제거",
|
||||
removing: "제거 중…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} 설치됨",
|
||||
reload: "{{id}} 다시 로드됨",
|
||||
enable: "{{id}} 활성화됨",
|
||||
disable: "{{id}} 비활성화됨",
|
||||
remove: "{{id}} 제거됨",
|
||||
kept: "{{id}} 유지됨",
|
||||
globalEnabled: "플러그인 활성화됨",
|
||||
globalDisabled: "플러그인 비활성화됨",
|
||||
},
|
||||
removeConfirmTitle: "{{id}}을(를) 제거할까요?",
|
||||
removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.",
|
||||
states: {
|
||||
offlineTitle: "플러그인 호스트가 오프라인입니다",
|
||||
offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.",
|
||||
updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요",
|
||||
loading: "플러그인 로드 중…",
|
||||
errorTitle: "플러그인을 로드할 수 없습니다",
|
||||
retry: "다시 시도",
|
||||
empty: "설정된 플러그인이 없습니다",
|
||||
},
|
||||
},
|
||||
"pt-BR": {
|
||||
...shared,
|
||||
title: "Plugins",
|
||||
trustedTitle: "Plugins são código confiável",
|
||||
trustedDescription:
|
||||
"Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.",
|
||||
globalTitle: "Ativar plugins",
|
||||
globalHint: "Controle global para todos os plugins configurados",
|
||||
directoryLabel: "Diretório do plugin",
|
||||
idLabel: "ID de instalação do plugin",
|
||||
idHint: "Deixe vazio para usar paseo-plugin.json",
|
||||
idPlaceholder: "Padrão do manifesto",
|
||||
install: "Instalar diretório",
|
||||
installing: "Instalando…",
|
||||
status: { running: "em execução", disabled: "desativado", failed: "falhou" },
|
||||
actions: {
|
||||
reload: "Recarregar",
|
||||
reloading: "Recarregando…",
|
||||
enable: "Ativar",
|
||||
enabling: "Ativando…",
|
||||
disable: "Desativar",
|
||||
disabling: "Desativando…",
|
||||
remove: "Remover",
|
||||
removing: "Removendo…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} instalado",
|
||||
reload: "{{id}} recarregado",
|
||||
enable: "{{id}} ativado",
|
||||
disable: "{{id}} desativado",
|
||||
remove: "{{id}} removido",
|
||||
kept: "{{id}} mantido",
|
||||
globalEnabled: "Plugins ativados",
|
||||
globalDisabled: "Plugins desativados",
|
||||
},
|
||||
removeConfirmTitle: "Remover {{id}}?",
|
||||
removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.",
|
||||
states: {
|
||||
offlineTitle: "O host de plugins está offline",
|
||||
offlineDescription: "Reconecte este host para gerenciar seus plugins.",
|
||||
updateTitle: "Atualize este host para gerenciar plugins",
|
||||
loading: "Carregando plugins…",
|
||||
errorTitle: "Não foi possível carregar os plugins",
|
||||
retry: "Tentar novamente",
|
||||
empty: "Nenhum plugin configurado",
|
||||
},
|
||||
},
|
||||
ru: {
|
||||
...shared,
|
||||
title: "Плагины",
|
||||
trustedTitle: "Плагины — доверенный код",
|
||||
trustedDescription:
|
||||
"Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.",
|
||||
globalTitle: "Включить плагины",
|
||||
globalHint: "Общий переключатель для всех настроенных плагинов",
|
||||
directoryLabel: "Каталог плагина",
|
||||
idLabel: "ID установки плагина",
|
||||
idHint: "Оставьте пустым, чтобы использовать paseo-plugin.json",
|
||||
idPlaceholder: "Значение из манифеста",
|
||||
install: "Установить каталог",
|
||||
installing: "Установка…",
|
||||
status: { running: "работает", disabled: "отключён", failed: "ошибка" },
|
||||
actions: {
|
||||
reload: "Перезагрузить",
|
||||
reloading: "Перезагрузка…",
|
||||
enable: "Включить",
|
||||
enabling: "Включение…",
|
||||
disable: "Отключить",
|
||||
disabling: "Отключение…",
|
||||
remove: "Удалить",
|
||||
removing: "Удаление…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} установлен",
|
||||
reload: "{{id}} перезагружен",
|
||||
enable: "{{id}} включён",
|
||||
disable: "{{id}} отключён",
|
||||
remove: "{{id}} удалён",
|
||||
kept: "{{id}} сохранён",
|
||||
globalEnabled: "Плагины включены",
|
||||
globalDisabled: "Плагины отключены",
|
||||
},
|
||||
removeConfirmTitle: "Удалить {{id}}?",
|
||||
removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.",
|
||||
states: {
|
||||
offlineTitle: "Хост плагинов не в сети",
|
||||
offlineDescription: "Подключите хост снова, чтобы управлять плагинами.",
|
||||
updateTitle: "Обновите хост для управления плагинами",
|
||||
loading: "Загрузка плагинов…",
|
||||
errorTitle: "Не удалось загрузить плагины",
|
||||
retry: "Повторить",
|
||||
empty: "Нет настроенных плагинов",
|
||||
},
|
||||
},
|
||||
"zh-CN": {
|
||||
...shared,
|
||||
title: "插件",
|
||||
trustedTitle: "插件是受信任的代码",
|
||||
trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。",
|
||||
globalTitle: "启用插件",
|
||||
globalHint: "所有已配置插件的全局开关",
|
||||
directoryLabel: "插件目录",
|
||||
idLabel: "插件安装 ID",
|
||||
idHint: "留空以使用 paseo-plugin.json",
|
||||
idPlaceholder: "清单默认值",
|
||||
install: "安装目录",
|
||||
installing: "正在安装…",
|
||||
status: { running: "运行中", disabled: "已禁用", failed: "失败" },
|
||||
actions: {
|
||||
reload: "重新加载",
|
||||
reloading: "正在重新加载…",
|
||||
enable: "启用",
|
||||
enabling: "正在启用…",
|
||||
disable: "禁用",
|
||||
disabling: "正在禁用…",
|
||||
remove: "移除",
|
||||
removing: "正在移除…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "已安装 {{id}}",
|
||||
reload: "已重新加载 {{id}}",
|
||||
enable: "已启用 {{id}}",
|
||||
disable: "已禁用 {{id}}",
|
||||
remove: "已移除 {{id}}",
|
||||
kept: "已保留 {{id}}",
|
||||
globalEnabled: "插件已启用",
|
||||
globalDisabled: "插件已禁用",
|
||||
},
|
||||
removeConfirmTitle: "移除 {{id}}?",
|
||||
removeConfirmMessage: "这会移除配置,但不会删除源目录。",
|
||||
states: {
|
||||
offlineTitle: "插件主机离线",
|
||||
offlineDescription: "重新连接此主机以管理其插件。",
|
||||
updateTitle: "更新此主机以管理插件",
|
||||
loading: "正在加载插件…",
|
||||
errorTitle: "无法加载插件",
|
||||
retry: "重试",
|
||||
empty: "没有已配置的插件",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ptBR: TranslationResources = {
|
||||
common: {
|
||||
@@ -1814,8 +1815,10 @@ export const ptBR: TranslationResources = {
|
||||
providers: "Provedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminais",
|
||||
plugins: "Plugins",
|
||||
host: "Visão geral",
|
||||
},
|
||||
plugins: pluginSettings["pt-BR"],
|
||||
metadataGeneration: {
|
||||
title: "Geração de metadados",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ru: TranslationResources = {
|
||||
common: {
|
||||
@@ -1819,8 +1820,10 @@ export const ru: TranslationResources = {
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
terminals: "Terminals",
|
||||
plugins: "Plugins",
|
||||
host: "Обзор",
|
||||
},
|
||||
plugins: pluginSettings.ru,
|
||||
metadataGeneration: {
|
||||
title: "Создание метаданных",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TranslationResources } from "./en";
|
||||
import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const zhCN: TranslationResources = {
|
||||
common: {
|
||||
@@ -1765,8 +1766,10 @@ export const zhCN: TranslationResources = {
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
terminals: "Terminals",
|
||||
plugins: "插件",
|
||||
host: "概览",
|
||||
},
|
||||
plugins: pluginSettings["zh-CN"],
|
||||
metadataGeneration: {
|
||||
title: "元数据生成",
|
||||
description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
PluginResourceComposerAttachmentSchema,
|
||||
pluginResourceAttachmentToAgentAttachment,
|
||||
type PluginResourceComposerAttachment,
|
||||
} from "./model";
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPluginResourceAttachment,
|
||||
PluginResourceComposerAttachmentSchema,
|
||||
pluginResourceAttachmentToAgentAttachment,
|
||||
togglePluginResourceAttachment,
|
||||
} from "./model";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
|
||||
const source = {
|
||||
pluginId: "linear",
|
||||
sourceId: "issues",
|
||||
sourceTitle: "Linear issue",
|
||||
sourceIcon: "CircleDot",
|
||||
};
|
||||
|
||||
const item = {
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
subtitle: "In progress · Mohamed",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments\nStatus: In progress",
|
||||
resourceType: "issue",
|
||||
};
|
||||
|
||||
describe("plugin resource attachments", () => {
|
||||
it("creates a draft-safe composer attachment", () => {
|
||||
const attachment = createPluginResourceAttachment(source, item);
|
||||
|
||||
expect(PluginResourceComposerAttachmentSchema.parse(attachment)).toEqual({
|
||||
kind: "plugin_resource",
|
||||
...source,
|
||||
item,
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles one resource by plugin, source, and stable item id", () => {
|
||||
const attachment = createPluginResourceAttachment(source, item);
|
||||
|
||||
expect(togglePluginResourceAttachment([], attachment)).toEqual([attachment]);
|
||||
expect(togglePluginResourceAttachment([attachment], attachment)).toEqual([]);
|
||||
});
|
||||
|
||||
it("submits through the backward-compatible text attachment", () => {
|
||||
const attachment = createPluginResourceAttachment(source, item);
|
||||
|
||||
const agentAttachment = pluginResourceAttachmentToAgentAttachment(attachment);
|
||||
expect(agentAttachment).toEqual({
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "ENG-123 Plugin attachments",
|
||||
text: item.text,
|
||||
externalResource: {
|
||||
provider: "linear",
|
||||
providerLabel: "Linear issue",
|
||||
resourceType: "issue",
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
url: item.url,
|
||||
},
|
||||
});
|
||||
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
|
||||
images: [],
|
||||
attachments: [agentAttachment],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@paseo/plugin";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
|
||||
export const PluginResourceComposerAttachmentSchema = z.object({
|
||||
kind: z.literal("plugin_resource"),
|
||||
pluginId: z.string().min(1),
|
||||
sourceId: z.string().min(1),
|
||||
sourceTitle: z.string().min(1),
|
||||
sourceIcon: z.string().min(1),
|
||||
item: PluginAttachmentItemSchema,
|
||||
});
|
||||
|
||||
export type PluginResourceComposerAttachment = z.infer<
|
||||
typeof PluginResourceComposerAttachmentSchema
|
||||
>;
|
||||
|
||||
export interface PluginResourceSourceIdentity {
|
||||
pluginId: string;
|
||||
sourceId: string;
|
||||
sourceTitle: string;
|
||||
sourceIcon: string;
|
||||
}
|
||||
|
||||
export function createPluginResourceAttachment(
|
||||
source: PluginResourceSourceIdentity,
|
||||
item: PluginAttachmentItem,
|
||||
): PluginResourceComposerAttachment {
|
||||
return {
|
||||
kind: "plugin_resource",
|
||||
...source,
|
||||
item,
|
||||
};
|
||||
}
|
||||
|
||||
export function togglePluginResourceAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
attachment: PluginResourceComposerAttachment,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (candidate: UserComposerAttachment) =>
|
||||
candidate.kind === "plugin_resource" &&
|
||||
candidate.pluginId === attachment.pluginId &&
|
||||
candidate.sourceId === attachment.sourceId &&
|
||||
candidate.item.id === attachment.item.id;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((candidate) => !matches(candidate));
|
||||
}
|
||||
return [...current, attachment];
|
||||
}
|
||||
|
||||
export function pluginResourceAttachmentToAgentAttachment(
|
||||
attachment: PluginResourceComposerAttachment,
|
||||
): AgentAttachment {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `${attachment.item.identifier} ${attachment.item.title}`,
|
||||
text: attachment.item.text,
|
||||
externalResource: {
|
||||
provider: attachment.pluginId,
|
||||
providerLabel: attachment.sourceTitle,
|
||||
resourceType: attachment.item.resourceType,
|
||||
id: attachment.item.id,
|
||||
identifier: attachment.item.identifier,
|
||||
title: attachment.item.title,
|
||||
url: attachment.item.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement, type RefObject } from "react";
|
||||
import { View } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@paseo/plugin";
|
||||
import { searchPluginAttachments } from "@paseo/plugin/host";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AttachmentMenuItem } from "@/composer/input/input";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
|
||||
|
||||
const SEARCH_STALE_TIME_MS = 30_000;
|
||||
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
|
||||
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
return <Icon size={ICON_SIZE.md} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedSourceIcon = withUnistyles(SourceIcon);
|
||||
|
||||
interface InstalledAttachmentSource {
|
||||
plugin: InstalledPlugin;
|
||||
source: PluginAttachmentSourceContribution;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface PluginAttachmentPickerInput {
|
||||
serverId: string;
|
||||
client: DaemonClient | null;
|
||||
connected: boolean;
|
||||
attachments: UserComposerAttachment[];
|
||||
onChangeAttachments: (attachments: UserComposerAttachment[]) => void;
|
||||
anchorRef: RefObject<View | null>;
|
||||
}
|
||||
|
||||
interface PluginAttachmentPickerBinding {
|
||||
menuItems: AttachmentMenuItem[];
|
||||
picker: ReactElement | null;
|
||||
}
|
||||
|
||||
function searchEmptyText(error: unknown, isFetching: boolean): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (error) return String(error);
|
||||
return isFetching ? "Searching..." : "No results";
|
||||
}
|
||||
|
||||
function installedAttachmentSources(
|
||||
plugins: InstalledPlugin[],
|
||||
serverId: string,
|
||||
): InstalledAttachmentSource[] {
|
||||
return plugins
|
||||
.filter((plugin) => plugin.serverId === serverId)
|
||||
.flatMap((plugin) =>
|
||||
plugin.attachmentSources.map((source) => ({
|
||||
plugin,
|
||||
source,
|
||||
key: `${plugin.id}/${source.id}`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function attachmentOptions(items: PluginAttachmentItem[]): ComboboxOption[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
label: `${item.identifier} ${item.title}`,
|
||||
description: item.subtitle,
|
||||
}));
|
||||
}
|
||||
|
||||
export function usePluginAttachmentPicker(
|
||||
input: PluginAttachmentPickerInput,
|
||||
): PluginAttachmentPickerBinding {
|
||||
const plugins = useInstalledPlugins();
|
||||
const sources = useMemo(
|
||||
() => installedAttachmentSources(plugins, input.serverId),
|
||||
[input.serverId, plugins],
|
||||
);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const active = sources.find((candidate) => candidate.key === activeKey) ?? null;
|
||||
const trimmedQuery = query.trim();
|
||||
const search = useFetchQuery(
|
||||
{
|
||||
queryKey: [
|
||||
"plugin-attachment-search",
|
||||
input.serverId,
|
||||
active?.plugin.id ?? "",
|
||||
active?.source.id ?? "",
|
||||
trimmedQuery,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!input.client || !active) throw new Error("Plugin host is offline");
|
||||
const client = input.client;
|
||||
return searchPluginAttachments(
|
||||
active.source,
|
||||
(method, rpcInput) => client.invokePluginRpc(active.plugin.id, method, rpcInput),
|
||||
trimmedQuery,
|
||||
);
|
||||
},
|
||||
enabled: input.connected && active !== null,
|
||||
dataShape: "list",
|
||||
staleTimeMs: SEARCH_STALE_TIME_MS,
|
||||
},
|
||||
active?.plugin.queryClient,
|
||||
);
|
||||
const items = search.error
|
||||
? EMPTY_ATTACHMENT_ITEMS
|
||||
: (search.data?.items ?? EMPTY_ATTACHMENT_ITEMS);
|
||||
const options = useMemo(() => attachmentOptions(items), [items]);
|
||||
const close = useCallback(() => {
|
||||
setActiveKey(null);
|
||||
setQuery("");
|
||||
}, []);
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) close();
|
||||
},
|
||||
[close],
|
||||
);
|
||||
const handleSelect = useCallback(
|
||||
(itemId: string) => {
|
||||
if (!active) return;
|
||||
const item = items.find((candidate) => candidate.id === itemId);
|
||||
if (!item) return;
|
||||
const attachment = createPluginResourceAttachment(
|
||||
{
|
||||
pluginId: active.plugin.id,
|
||||
sourceId: active.source.id,
|
||||
sourceTitle: active.source.title,
|
||||
sourceIcon: active.source.icon,
|
||||
},
|
||||
item,
|
||||
);
|
||||
input.onChangeAttachments(togglePluginResourceAttachment(input.attachments, attachment));
|
||||
close();
|
||||
},
|
||||
[active, close, input, items],
|
||||
);
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
sources.map(({ key, source }) => {
|
||||
const Icon = resolvePluginIcon(source.icon);
|
||||
return {
|
||||
id: `plugin:${key}`,
|
||||
label: `Attach ${source.title}`,
|
||||
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
|
||||
onSelect: () => setActiveKey(key),
|
||||
};
|
||||
}),
|
||||
[sources],
|
||||
);
|
||||
if (!active) return { menuItems, picker: null };
|
||||
return {
|
||||
menuItems,
|
||||
picker: (
|
||||
<Combobox
|
||||
options={options}
|
||||
value=""
|
||||
onSelect={handleSelect}
|
||||
searchable
|
||||
searchPlaceholder={active.source.searchPlaceholder}
|
||||
title={active.source.pickerTitle}
|
||||
open
|
||||
onOpenChange={handleOpenChange}
|
||||
onSearchQueryChange={setQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={input.anchorRef}
|
||||
emptyText={searchEmptyText(search.error, search.isFetching)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import type { PluginResourceComposerAttachment } from "./model";
|
||||
|
||||
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function ResourceIcon({
|
||||
Icon,
|
||||
color = "",
|
||||
}: {
|
||||
Icon: ReturnType<typeof resolvePluginIcon>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={ICON_SIZE.sm} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedResourceIcon = withUnistyles(ResourceIcon);
|
||||
|
||||
interface PluginResourceAttachmentPillProps {
|
||||
attachment: PluginResourceComposerAttachment;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
openLabel: (kind: string, identifier: string) => string;
|
||||
removeLabel: (kind: string, identifier: string) => string;
|
||||
}
|
||||
|
||||
export function PluginResourceAttachmentPill({
|
||||
attachment,
|
||||
index,
|
||||
disabled,
|
||||
onOpen,
|
||||
onRemove,
|
||||
openLabel,
|
||||
removeLabel,
|
||||
}: PluginResourceAttachmentPillProps) {
|
||||
const Icon = resolvePluginIcon(attachment.sourceIcon);
|
||||
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
|
||||
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
|
||||
const icon = useMemo(
|
||||
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
|
||||
[Icon],
|
||||
);
|
||||
return (
|
||||
<AttachmentPill
|
||||
testID="composer-plugin-resource-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={openLabel(attachment.sourceTitle, attachment.item.identifier)}
|
||||
removeAccessibilityLabel={removeLabel(attachment.sourceTitle, attachment.item.identifier)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={icon}
|
||||
title={attachment.item.title}
|
||||
subtitle={`${attachment.sourceTitle} ${attachment.item.identifier}`}
|
||||
/>
|
||||
</AttachmentPill>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
const connection = vi.hoisted(() => ({ connected: true, supported: true }));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeIsConnected: () => connection.connected,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: () => connection.supported,
|
||||
}));
|
||||
|
||||
import { PluginCatalogSync } from "./catalog-sync";
|
||||
import { pluginRegistry } from "./registry";
|
||||
|
||||
function bundle(): string {
|
||||
return `(function() { return { default: function(plugin) {
|
||||
plugin.addSurface("main", function() { return null; });
|
||||
return function() { globalThis.__catalogSyncCleanups = (globalThis.__catalogSyncCleanups || 0) + 1; };
|
||||
} }; })`;
|
||||
}
|
||||
|
||||
const unsubscribe = vi.fn();
|
||||
const catalogClient = {
|
||||
getPluginCatalog: vi.fn(async () => [{ id: "example", clientBundle: bundle() }]),
|
||||
on: vi.fn(() => unsubscribe),
|
||||
} as unknown as DaemonClient;
|
||||
|
||||
describe("PluginCatalogSync", () => {
|
||||
let container: HTMLElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
connection.connected = true;
|
||||
connection.supported = true;
|
||||
unsubscribe.mockReset();
|
||||
vi.mocked(catalogClient.getPluginCatalog).mockClear();
|
||||
vi.mocked(catalogClient.on).mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
pluginRegistry.removeHost("host-a");
|
||||
container.remove();
|
||||
Reflect.deleteProperty(globalThis, "__catalogSyncCleanups");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderSync(): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<PluginCatalogSync serverId="host-a" client={catalogClient} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("tears down through disconnect and unmount boundaries exactly once each", async () => {
|
||||
await renderSync();
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
expect(first?.id).toBe("example");
|
||||
first?.queryClient.setQueryData(["owned"], "state");
|
||||
|
||||
connection.connected = false;
|
||||
await renderSync();
|
||||
|
||||
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(1);
|
||||
expect(first?.queryClient.getQueryCache().getAll()).toEqual([]);
|
||||
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
||||
|
||||
connection.connected = true;
|
||||
await renderSync();
|
||||
expect(pluginRegistry.getSnapshot()).toHaveLength(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(2);
|
||||
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { pluginRegistry } from "./registry";
|
||||
|
||||
export function PluginCatalogSync({
|
||||
serverId,
|
||||
client,
|
||||
}: {
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
}) {
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "plugins");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshQueue = Promise.resolve();
|
||||
if (!supported) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
if (!connected) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
const refresh = (replacePluginId?: string) => {
|
||||
refreshQueue = refreshQueue.then(() =>
|
||||
client
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
pluginRegistry.installCatalog(serverId, catalog, { replacePluginId });
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
return refreshQueue;
|
||||
};
|
||||
void refresh();
|
||||
const unsubscribe = client.on("status", (message) => {
|
||||
if (message.payload.status === "plugin_catalog_changed") {
|
||||
const pluginId = message.payload.pluginId;
|
||||
if (typeof pluginId === "string") void refresh(pluginId);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, connected, serverId, supported]);
|
||||
|
||||
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluatePluginClientBundle } from "./evaluate";
|
||||
|
||||
function bundle(body: string): string {
|
||||
return `(function() {
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) { ${body}; return function() {}; };
|
||||
return module.exports;
|
||||
})`;
|
||||
}
|
||||
|
||||
describe("evaluatePluginClientBundle", () => {
|
||||
it("collects a surface and its sidebar placement", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Surface() { return null; }
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
|
||||
`),
|
||||
);
|
||||
|
||||
expect(plugin.id).toBe("example");
|
||||
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
|
||||
expect(plugin.sidebarItems).toEqual([
|
||||
{ id: "main", title: "Example", icon: "Blocks", surface: "main" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects a declarative attachment source", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"linear",
|
||||
bundle(`
|
||||
plugin.addAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Linear issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Linear issue",
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: { name: "issues.search", input: {}, output: {} },
|
||||
});
|
||||
`),
|
||||
);
|
||||
|
||||
expect(plugin.attachmentSources).toEqual([
|
||||
{
|
||||
id: "issues",
|
||||
title: "Linear issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Linear issue",
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: { name: "issues.search", input: {}, output: {} },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects duplicate attachment source ids", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"linear",
|
||||
bundle(`
|
||||
const source = {
|
||||
id: "issues",
|
||||
title: "Linear issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach Linear issue",
|
||||
searchPlaceholder: "Search",
|
||||
search: { name: "issues.search", input: {}, output: {} },
|
||||
};
|
||||
plugin.addAttachmentSource(source);
|
||||
plugin.addAttachmentSource(source);
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate attachment source: issues");
|
||||
});
|
||||
|
||||
it("rejects a sidebar placement whose surface does not exist", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "missing" });
|
||||
`),
|
||||
),
|
||||
).toThrow("references missing surface missing");
|
||||
});
|
||||
|
||||
it("rejects a bundle without a default contribution function", () => {
|
||||
expect(() => evaluatePluginClientBundle("example", `(function() { return {}; })`)).toThrow(
|
||||
"must default export a function",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a cleanup function", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle("example", `(function() { return { default: function() {} }; })`),
|
||||
).toThrow("must return a cleanup function");
|
||||
});
|
||||
|
||||
it("does not publish partial contributions when setup fails", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
`(function() { return { default: function(plugin) {
|
||||
plugin.addSurface("main", function() { return null; });
|
||||
throw new Error("setup exploded");
|
||||
} }; })`,
|
||||
),
|
||||
).toThrow("setup exploded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as React from "react";
|
||||
import * as ReactJsxRuntime from "react/jsx-runtime";
|
||||
import * as ReactNative from "react-native";
|
||||
// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers.
|
||||
import * as ReactQuery from "@tanstack/react-query";
|
||||
import * as Zod from "zod";
|
||||
import {
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginSidebarContribution,
|
||||
type PluginSurfaceProps,
|
||||
useRpc,
|
||||
} from "@paseo/plugin";
|
||||
import { createPluginContext, type PluginRegistrationCollector } from "@paseo/plugin/host";
|
||||
import type { EvaluatedPlugin } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
function requireId(value: string, label: string): string {
|
||||
const id = value.trim();
|
||||
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin {
|
||||
const collector: PluginRegistrationCollector = {
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
attachmentSources: [],
|
||||
};
|
||||
const surfaceIds = new Set<string>();
|
||||
const sidebarItemIds = new Set<string>();
|
||||
const attachmentSourceIds = new Set<string>();
|
||||
const pluginContext = createPluginContext({
|
||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||
const normalizedId = requireId(surfaceId, "surface id");
|
||||
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
|
||||
if (typeof Component !== "function")
|
||||
throw new Error(`Surface ${normalizedId} is not a component`);
|
||||
surfaceIds.add(normalizedId);
|
||||
collector.surfaces.push({ id: normalizedId, Component });
|
||||
},
|
||||
addSidebarItem(contribution: PluginSidebarContribution) {
|
||||
const normalizedId = requireId(contribution.id, "sidebar item id");
|
||||
if (sidebarItemIds.has(normalizedId))
|
||||
throw new Error(`Duplicate sidebar item: ${normalizedId}`);
|
||||
if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`);
|
||||
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
|
||||
resolvePluginIcon(contribution.icon.trim());
|
||||
sidebarItemIds.add(normalizedId);
|
||||
collector.sidebarItems.push({
|
||||
id: normalizedId,
|
||||
title: contribution.title.trim(),
|
||||
icon: contribution.icon.trim(),
|
||||
surface: requireId(contribution.surface, "sidebar surface id"),
|
||||
});
|
||||
},
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
|
||||
const normalizedId = requireId(contribution.id, "attachment source id");
|
||||
if (attachmentSourceIds.has(normalizedId)) {
|
||||
throw new Error(`Duplicate attachment source: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const icon = contribution.icon.trim();
|
||||
const pickerTitle = contribution.pickerTitle.trim();
|
||||
const searchPlaceholder = contribution.searchPlaceholder.trim();
|
||||
const method = contribution.search.name.trim();
|
||||
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
|
||||
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
|
||||
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
|
||||
if (!searchPlaceholder) {
|
||||
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
|
||||
}
|
||||
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
|
||||
resolvePluginIcon(icon);
|
||||
attachmentSourceIds.add(normalizedId);
|
||||
collector.attachmentSources.push({
|
||||
id: normalizedId,
|
||||
title,
|
||||
icon,
|
||||
pickerTitle,
|
||||
searchPlaceholder,
|
||||
search: { ...contribution.search, name: method },
|
||||
});
|
||||
},
|
||||
});
|
||||
const runtimeRequire = (name: string): unknown => {
|
||||
if (name === "react") return React;
|
||||
if (name === "react/jsx-runtime") return ReactJsxRuntime;
|
||||
if (name === "react-native") return ReactNative;
|
||||
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc, useRpc };
|
||||
if (name === "@tanstack/react-query") return ReactQuery;
|
||||
if (name === "zod") return Zod;
|
||||
throw new Error(`Module "${name}" is not available in plugin client code`);
|
||||
};
|
||||
const evaluate: (source: string) => unknown = globalThis.eval;
|
||||
const factory = evaluate(bundle);
|
||||
if (typeof factory !== "function")
|
||||
throw new Error(`Plugin ${id} client bundle is not executable`);
|
||||
const exports = factory(runtimeRequire);
|
||||
const setup =
|
||||
exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined;
|
||||
if (typeof setup !== "function") {
|
||||
throw new Error(`Plugin ${id} must default export a function`);
|
||||
}
|
||||
const cleanup = setup(pluginContext);
|
||||
if (typeof cleanup !== "function") {
|
||||
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const item of collector.sidebarItems) {
|
||||
if (!surfaceIds.has(item.surface)) {
|
||||
throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (cleanupError) {
|
||||
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
cleanup,
|
||||
surfaces: collector.surfaces,
|
||||
sidebarItems: collector.sidebarItems,
|
||||
attachmentSources: collector.attachmentSources,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as LucideIcons from "lucide-react-native";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
|
||||
export function resolvePluginIcon(name: string): LucideIcon {
|
||||
const candidate = Reflect.get(LucideIcons, name);
|
||||
const isComponent =
|
||||
typeof candidate === "function" ||
|
||||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
|
||||
if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`);
|
||||
return candidate as LucideIcon;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { PluginCatalogSync } from "./catalog-sync";
|
||||
export { PluginSidebarItems } from "./sidebar-items";
|
||||
export { PluginSurfaceScreen } from "./surface-screen";
|
||||
export { usePluginAttachmentPicker } from "./attachments/picker";
|
||||
export { PluginResourceAttachmentPill } from "./attachments/pill";
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { pluginRegistry } from "./registry";
|
||||
|
||||
function bundle(marker: string): string {
|
||||
return `(function() {
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) {
|
||||
function Surface() { return ${JSON.stringify(marker)}; }
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
|
||||
return function() { globalThis.__pluginCleanups = (globalThis.__pluginCleanups || 0) + 1; };
|
||||
};
|
||||
return module.exports;
|
||||
})`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
pluginRegistry.removeHost("host-a");
|
||||
pluginRegistry.removeHost("host-b");
|
||||
Reflect.deleteProperty(globalThis, "__pluginCleanups");
|
||||
});
|
||||
|
||||
describe("PluginRegistry", () => {
|
||||
it("preserves a plugin query cache when the same bundle reconnects", () => {
|
||||
const clientBundle = bundle("one");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
first?.queryClient.setQueryData(["counter"], 3);
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
||||
|
||||
const second = pluginRegistry.getSnapshot()[0];
|
||||
expect(second).toBe(first);
|
||||
expect(second?.queryClient).toBe(first?.queryClient);
|
||||
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
|
||||
});
|
||||
|
||||
it("replaces the query cache when the plugin bundle changes", () => {
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
first?.queryClient.setQueryData(["counter"], 3);
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("two") }]);
|
||||
|
||||
const second = pluginRegistry.getSnapshot()[0];
|
||||
expect(second?.queryClient).not.toBe(first?.queryClient);
|
||||
expect(second?.queryClient.getQueryData(["counter"])).toBeUndefined();
|
||||
expect(first?.queryClient.getQueryData(["counter"])).toBeUndefined();
|
||||
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
||||
});
|
||||
|
||||
it("isolates query caches between hosts", () => {
|
||||
const clientBundle = bundle("same");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
||||
pluginRegistry.installCatalog("host-b", [{ id: "example", clientBundle }]);
|
||||
const [hostA, hostB] = pluginRegistry.getSnapshot();
|
||||
hostA?.queryClient.setQueryData(["counter"], 3);
|
||||
|
||||
expect(hostB?.queryClient.getQueryData(["counter"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cleans up the old installation before evaluating its replacement", () => {
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
||||
const replacement = `(function() { return { default: function(plugin) {
|
||||
globalThis.__cleanupSeenByReplacement = globalThis.__pluginCleanups || 0;
|
||||
plugin.addSurface("main", function() { return null; });
|
||||
return function() {};
|
||||
} }; })`;
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: replacement }]);
|
||||
|
||||
expect(Reflect.get(globalThis, "__cleanupSeenByReplacement")).toBe(1);
|
||||
Reflect.deleteProperty(globalThis, "__cleanupSeenByReplacement");
|
||||
});
|
||||
|
||||
it("replaces an unchanged bundle when explicit reload names the plugin", () => {
|
||||
const clientBundle = bundle("same");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
first?.queryClient.setQueryData(["attachment-search"], "cached");
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
|
||||
replacePluginId: "example",
|
||||
});
|
||||
|
||||
const second = pluginRegistry.getSnapshot()[0];
|
||||
expect(second).not.toBe(first);
|
||||
expect(first?.queryClient.getQueryData(["attachment-search"])).toBeUndefined();
|
||||
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
||||
});
|
||||
|
||||
it.each(["disable", "remove"])("cleans a catalog entry exactly once on %s", () => {
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
||||
|
||||
pluginRegistry.installCatalog("host-a", []);
|
||||
pluginRegistry.installCatalog("host-a", []);
|
||||
|
||||
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
||||
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancels an in-flight attachment query during teardown", async () => {
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
||||
const plugin = pluginRegistry.getSnapshot()[0];
|
||||
if (!plugin) throw new Error("Expected installed plugin");
|
||||
let observeAbort: () => void = () => undefined;
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
observeAbort = resolve;
|
||||
});
|
||||
void plugin.queryClient
|
||||
.fetchQuery({
|
||||
queryKey: ["plugin-attachment-search", "example"],
|
||||
queryFn: ({ signal }) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
observeAbort();
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
pluginRegistry.installCatalog("host-a", []);
|
||||
|
||||
await aborted;
|
||||
expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]);
|
||||
});
|
||||
|
||||
it("continues host teardown when cleanup throws", () => {
|
||||
const throwing = `(function() { return { default: function(plugin) {
|
||||
plugin.addSurface("main", function() { return null; });
|
||||
return function() { throw new Error("cleanup exploded"); };
|
||||
} }; })`;
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: throwing }]);
|
||||
pluginRegistry.getSnapshot()[0]?.queryClient.setQueryData(["counter"], 3);
|
||||
|
||||
expect(() => pluginRegistry.removeHost("host-a")).not.toThrow();
|
||||
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { evaluatePluginClientBundle } from "./evaluate";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
interface CatalogPlugin {
|
||||
id: string;
|
||||
clientBundle: string;
|
||||
}
|
||||
|
||||
class PluginRegistry {
|
||||
private readonly byHost = new Map<string, InstalledPlugin[]>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: InstalledPlugin[] = [];
|
||||
private readonly disposed = new WeakSet<InstalledPlugin>();
|
||||
private readonly evaluationErrors = new Map<string, string>();
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): InstalledPlugin[] => this.snapshot;
|
||||
|
||||
getEvaluationError(serverId: string, pluginId: string): string | undefined {
|
||||
return this.evaluationErrors.get(`${serverId}/${pluginId}`);
|
||||
}
|
||||
|
||||
installCatalog(
|
||||
serverId: string,
|
||||
catalog: CatalogPlugin[],
|
||||
options: { replacePluginId?: string } = {},
|
||||
): void {
|
||||
const previous = this.byHost.get(serverId) ?? [];
|
||||
const preserved = catalog.flatMap((entry) => {
|
||||
const existing = previous.find(
|
||||
(plugin) =>
|
||||
plugin.id !== options.replacePluginId &&
|
||||
plugin.id === entry.id &&
|
||||
plugin.clientBundle === entry.clientBundle,
|
||||
);
|
||||
return existing ? [existing] : [];
|
||||
});
|
||||
const removed = previous.filter((plugin) => !preserved.includes(plugin));
|
||||
if (removed.length > 0) {
|
||||
this.byHost.set(serverId, preserved);
|
||||
this.publish();
|
||||
for (const plugin of removed) this.dispose(plugin);
|
||||
}
|
||||
const installed = catalog.flatMap((entry) => {
|
||||
const key = `${serverId}/${entry.id}`;
|
||||
try {
|
||||
const existing = preserved.find(
|
||||
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
|
||||
);
|
||||
if (existing) {
|
||||
this.evaluationErrors.delete(key);
|
||||
return [existing];
|
||||
}
|
||||
const queryClient = new QueryClient();
|
||||
const evaluated = [
|
||||
{
|
||||
...evaluatePluginClientBundle(entry.id, entry.clientBundle),
|
||||
serverId,
|
||||
clientBundle: entry.clientBundle,
|
||||
queryClient,
|
||||
},
|
||||
];
|
||||
this.evaluationErrors.delete(key);
|
||||
return evaluated;
|
||||
} catch (error) {
|
||||
this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error));
|
||||
console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const configuredIds = new Set(catalog.map((entry) => entry.id));
|
||||
for (const key of this.evaluationErrors.keys()) {
|
||||
if (key.startsWith(`${serverId}/`) && !configuredIds.has(key.slice(serverId.length + 1))) {
|
||||
this.evaluationErrors.delete(key);
|
||||
}
|
||||
}
|
||||
this.byHost.set(serverId, installed);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
removeHost(serverId: string): void {
|
||||
const installed = this.byHost.get(serverId);
|
||||
if (!installed) return;
|
||||
for (const plugin of installed) this.dispose(plugin);
|
||||
for (const key of this.evaluationErrors.keys()) {
|
||||
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
|
||||
}
|
||||
this.byHost.delete(serverId);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private dispose(plugin: InstalledPlugin): void {
|
||||
if (this.disposed.has(plugin)) return;
|
||||
this.disposed.add(plugin);
|
||||
plugin.queryClient.clear();
|
||||
try {
|
||||
plugin.cleanup();
|
||||
} catch (error) {
|
||||
console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
this.snapshot = [...this.byHost.values()]
|
||||
.flat()
|
||||
.sort((left, right) =>
|
||||
`${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`),
|
||||
);
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginRegistry = new PluginRegistry();
|
||||
|
||||
export function useInstalledPlugins(): InstalledPlugin[] {
|
||||
return useSyncExternalStore(
|
||||
pluginRegistry.subscribe,
|
||||
pluginRegistry.getSnapshot,
|
||||
pluginRegistry.getSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
export function useInstalledPlugin(serverId: string, pluginId: string): InstalledPlugin | null {
|
||||
return (
|
||||
useInstalledPlugins().find(
|
||||
(plugin) => plugin.serverId === serverId && plugin.id === pluginId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
|
||||
return useInstalledPlugins().filter((plugin) => plugin.id === pluginId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function buildPluginSurfaceRoute(
|
||||
serverId: string,
|
||||
pluginId: string,
|
||||
contributionId: string,
|
||||
): `/h/${string}/plugin/${string}/${string}` {
|
||||
return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`;
|
||||
}
|
||||
|
||||
export function hostIdFromPathname(pathname: string): string | null {
|
||||
const encoded = /^\/h\/([^/]+)/.exec(pathname)?.[1];
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
return decodeURIComponent(encoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
import { groupPluginSidebarContributions } from "./sidebar-groups";
|
||||
|
||||
function installed(serverId: string, contributionId = "main"): InstalledPlugin {
|
||||
return {
|
||||
id: "example",
|
||||
cleanup: () => undefined,
|
||||
serverId,
|
||||
clientBundle: serverId,
|
||||
queryClient: new QueryClient(),
|
||||
surfaces: [{ id: "surface", Component: () => null }],
|
||||
sidebarItems: [
|
||||
{
|
||||
id: contributionId,
|
||||
title: "Example",
|
||||
icon: "Blocks",
|
||||
surface: "surface",
|
||||
},
|
||||
],
|
||||
attachmentSources: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupPluginSidebarContributions", () => {
|
||||
it("coalesces the same plugin contribution across hosts", () => {
|
||||
const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([
|
||||
"host-a",
|
||||
"host-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps different contribution ids separate", () => {
|
||||
const groups = groupPluginSidebarContributions([
|
||||
installed("host-a", "main"),
|
||||
installed("host-b", "settings"),
|
||||
]);
|
||||
|
||||
expect(groups.map((group) => group.key)).toEqual(["example/main", "example/settings"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
|
||||
|
||||
const preferredHosts = new Map<string, string>();
|
||||
|
||||
export interface PluginSidebarTarget {
|
||||
plugin: InstalledPlugin;
|
||||
item: PluginSidebarContribution;
|
||||
}
|
||||
|
||||
export interface PluginSidebarGroup {
|
||||
key: string;
|
||||
pluginId: string;
|
||||
contributionId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
targets: PluginSidebarTarget[];
|
||||
}
|
||||
|
||||
export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] {
|
||||
const groups = new Map<string, PluginSidebarGroup>();
|
||||
for (const plugin of plugins) {
|
||||
for (const item of plugin.sidebarItems) {
|
||||
const key = `${plugin.id}/${item.id}`;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.targets.push({ plugin, item });
|
||||
} else {
|
||||
groups.set(key, {
|
||||
key,
|
||||
pluginId: plugin.id,
|
||||
contributionId: item.id,
|
||||
title: item.title,
|
||||
icon: item.icon,
|
||||
targets: [{ plugin, item }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
export function getPreferredPluginContributionHost(groupKey: string): string | null {
|
||||
return preferredHosts.get(groupKey) ?? null;
|
||||
}
|
||||
|
||||
export function rememberPluginContributionHost(groupKey: string, serverId: string): void {
|
||||
preferredHosts.set(groupKey, serverId);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { useInstalledPlugins } from "./registry";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
|
||||
import {
|
||||
groupPluginSidebarContributions,
|
||||
getPreferredPluginContributionHost,
|
||||
rememberPluginContributionHost,
|
||||
type PluginSidebarGroup,
|
||||
type PluginSidebarTarget,
|
||||
} from "./sidebar-groups";
|
||||
|
||||
function selectTarget(
|
||||
group: PluginSidebarGroup,
|
||||
currentHostId: string | null,
|
||||
): PluginSidebarTarget {
|
||||
const current = group.targets.find((target) => target.plugin.serverId === currentHostId);
|
||||
if (current) return current;
|
||||
const rememberedHostId = getPreferredPluginContributionHost(group.key);
|
||||
const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId);
|
||||
return remembered ?? group.targets[0];
|
||||
}
|
||||
|
||||
export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
|
||||
const plugins = useInstalledPlugins();
|
||||
const pathname = usePathname();
|
||||
const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
|
||||
const currentHostId = hostIdFromPathname(pathname);
|
||||
|
||||
return groups.map((group) => (
|
||||
<PluginSidebarItemRow
|
||||
key={group.key}
|
||||
group={group}
|
||||
currentHostId={currentHostId}
|
||||
pathname={pathname}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function PluginSidebarItemRow({
|
||||
group,
|
||||
currentHostId,
|
||||
pathname,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
group: PluginSidebarGroup;
|
||||
currentHostId: string | null;
|
||||
pathname: string;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
const target = selectTarget(group, currentHostId);
|
||||
const route = buildPluginSurfaceRoute(
|
||||
target.plugin.serverId,
|
||||
group.pluginId,
|
||||
group.contributionId,
|
||||
);
|
||||
const isActive = group.targets.some(
|
||||
(candidate) =>
|
||||
pathname ===
|
||||
buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId),
|
||||
);
|
||||
const navigate = useCallback(() => {
|
||||
rememberPluginContributionHost(group.key, target.plugin.serverId);
|
||||
onBeforeNavigate?.();
|
||||
router.push(route);
|
||||
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={resolvePluginIcon(group.icon)}
|
||||
label={group.title}
|
||||
onPress={navigate}
|
||||
isActive={isActive}
|
||||
testID={`plugin-sidebar-${group.pluginId}-${group.contributionId}`}
|
||||
variant="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act, useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Text } from "react-native";
|
||||
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
||||
|
||||
let oldSurfaceCleanups = 0;
|
||||
function FailingSurface() {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
setFailed(true);
|
||||
return () => {
|
||||
oldSurfaceCleanups += 1;
|
||||
};
|
||||
}, []);
|
||||
if (failed) throw new Error("old render exploded");
|
||||
return <Text>Old surface mounted</Text>;
|
||||
}
|
||||
function CorrectedSurface() {
|
||||
return <Text>Corrected surface</Text>;
|
||||
}
|
||||
const oldInstallation = {};
|
||||
const correctedInstallation = {};
|
||||
|
||||
describe("SurfaceErrorBoundary", () => {
|
||||
const roots: Array<ReturnType<typeof createRoot>> = [];
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) act(() => root.unmount());
|
||||
for (const container of containers.splice(0)) container.remove();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
oldSurfaceCleanups = 0;
|
||||
});
|
||||
|
||||
it("recovers a mounted failed route when its installation and surface are replaced", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SurfaceErrorBoundary
|
||||
key="host/plugin/surface"
|
||||
installation={oldInstallation}
|
||||
Surface={FailingSurface}
|
||||
>
|
||||
<FailingSurface />
|
||||
</SurfaceErrorBoundary>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toBe("Plugin failed: old render exploded");
|
||||
expect(oldSurfaceCleanups).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SurfaceErrorBoundary
|
||||
key="host/plugin/surface"
|
||||
installation={correctedInstallation}
|
||||
Surface={CorrectedSurface}
|
||||
>
|
||||
<CorrectedSurface />
|
||||
</SurfaceErrorBoundary>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("Corrected surface");
|
||||
expect(oldSurfaceCleanups).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
interface SurfaceErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
installation: object;
|
||||
Surface: unknown;
|
||||
}
|
||||
|
||||
interface SurfaceErrorBoundaryState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export class SurfaceErrorBoundary extends Component<
|
||||
SurfaceErrorBoundaryProps,
|
||||
SurfaceErrorBoundaryState
|
||||
> {
|
||||
state: SurfaceErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown): SurfaceErrorBoundaryState {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.warn("[Plugins] Surface render failed", error, info.componentStack);
|
||||
}
|
||||
|
||||
componentDidUpdate(previous: SurfaceErrorBoundaryProps): void {
|
||||
if (
|
||||
this.state.error &&
|
||||
(previous.installation !== this.props.installation || previous.Surface !== this.props.Surface)
|
||||
) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <Text style={styles.errorText}>Plugin failed: {this.state.error}</Text>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
errorText: {
|
||||
color: theme.colors.statusDanger,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,270 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { PluginSurfaceProps } from "@paseo/plugin";
|
||||
import { PluginRpcProvider } from "@paseo/plugin/host";
|
||||
import { ChevronDown, X } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { Platform, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
||||
import { buildPluginSurfaceRoute } from "./routes";
|
||||
import { rememberPluginContributionHost } from "./sidebar-groups";
|
||||
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
||||
|
||||
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
|
||||
const EMPTY_THEME_DTO: Record<string, unknown> = {};
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const pluginThemeMapping = (theme: Theme) => ({
|
||||
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
|
||||
});
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
function routeParam(value: string | string[] | undefined): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function PluginHeaderIcon({
|
||||
Icon,
|
||||
color = "",
|
||||
}: {
|
||||
Icon: ReturnType<typeof resolvePluginIcon>;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={16} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
|
||||
|
||||
function SurfaceRenderer({
|
||||
Surface,
|
||||
invoke,
|
||||
plugin,
|
||||
layout,
|
||||
host,
|
||||
themeDto = EMPTY_THEME_DTO,
|
||||
}: {
|
||||
Surface: ComponentType<PluginSurfaceProps>;
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
|
||||
layout: PluginSurfaceProps["layout"];
|
||||
host: PluginSurfaceProps["host"];
|
||||
themeDto?: Record<string, unknown>;
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PluginRpcProvider invoke={invoke}>
|
||||
<Surface theme={themeDto} host={host} layout={layout} />
|
||||
</PluginRpcProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer);
|
||||
|
||||
function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
function PluginHostSwitcher({
|
||||
serverId,
|
||||
pluginId,
|
||||
contributionId,
|
||||
serverIds,
|
||||
}: {
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
contributionId: string;
|
||||
serverIds: string[];
|
||||
}) {
|
||||
const allHosts = useHosts();
|
||||
const hosts = useMemo(
|
||||
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
|
||||
[allHosts, serverIds],
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<View | null>(null);
|
||||
const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const selectHost = useCallback(
|
||||
(nextServerId: string) => {
|
||||
rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId);
|
||||
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId));
|
||||
},
|
||||
[contributionId, pluginId],
|
||||
);
|
||||
const openPicker = useCallback(() => setOpen(true), []);
|
||||
const show = serverIds.length > 1 && hosts.length > 1;
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={serverId}
|
||||
onSelect={selectHost}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
title="Choose plugin host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Plugin host: ${selectedLabel}`}
|
||||
testID="plugin-host-switcher"
|
||||
onPress={openPicker}
|
||||
style={styles.hostSwitcher}
|
||||
>
|
||||
<Text numberOfLines={1} style={styles.hostSwitcherText}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginSurfaceScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
pluginId?: string | string[];
|
||||
surfaceId?: string | string[];
|
||||
}>();
|
||||
const serverId = routeParam(params.serverId);
|
||||
const pluginId = routeParam(params.pluginId);
|
||||
const contributionId = routeParam(params.surfaceId);
|
||||
const plugin = useInstalledPlugin(serverId, pluginId);
|
||||
const installations = usePluginInstallations(pluginId);
|
||||
const hosts = useHosts();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const compact = useIsCompactFormFactor();
|
||||
const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null;
|
||||
const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null;
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const contributionServerIds = useMemo(
|
||||
() =>
|
||||
installations
|
||||
.filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId))
|
||||
.map((entry) => entry.serverId),
|
||||
[contributionId, installations],
|
||||
);
|
||||
const title = sidebarItem?.title ?? (pluginId || "Plugin");
|
||||
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
|
||||
const invoke = useCallback(
|
||||
async (method: string, input: unknown) => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.invokePluginRpc(pluginId, method, input);
|
||||
},
|
||||
[client, pluginId],
|
||||
);
|
||||
const close = useCallback(() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
else router.replace(`/h/${encodeURIComponent(serverId)}`);
|
||||
}, [serverId]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
|
||||
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
|
||||
const headerLeft = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{Icon ? (
|
||||
<HeaderIconBadge>
|
||||
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
|
||||
</HeaderIconBadge>
|
||||
) : null}
|
||||
<ScreenTitle>{title}</ScreenTitle>
|
||||
</>
|
||||
),
|
||||
[Icon, title],
|
||||
);
|
||||
const headerRight = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<PluginHostSwitcher
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
contributionId={contributionId}
|
||||
serverIds={contributionServerIds}
|
||||
/>
|
||||
<HeaderToggleButton
|
||||
accessibilityLabel="Close plugin"
|
||||
onPress={close}
|
||||
testID="plugin-surface-close"
|
||||
tooltipKeys={EMPTY_SHORTCUT_KEYS}
|
||||
tooltipLabel="Close"
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<ThemedX size={18} uniProps={mutedColorMapping} />
|
||||
</HeaderToggleButton>
|
||||
</>
|
||||
),
|
||||
[close, contributionId, contributionServerIds, pluginId, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<ScreenHeader left={headerLeft} right={headerRight} />
|
||||
<View style={styles.body}>
|
||||
{plugin && surface ? (
|
||||
<SurfaceErrorBoundary
|
||||
key={`${serverId}/${pluginId}/${contributionId}`}
|
||||
installation={plugin}
|
||||
Surface={surface.Component}
|
||||
>
|
||||
<ThemedSurfaceRenderer
|
||||
Surface={surface.Component}
|
||||
invoke={invoke}
|
||||
plugin={plugin}
|
||||
host={host}
|
||||
layout={layout}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
</SurfaceErrorBoundary>
|
||||
) : (
|
||||
<Text style={styles.errorText}>This plugin surface is unavailable.</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.statusDanger,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
hostSwitcher: {
|
||||
maxWidth: 180,
|
||||
minHeight: 32,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
hostSwitcherText: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
} from "@paseo/plugin";
|
||||
|
||||
export interface EvaluatedPlugin {
|
||||
id: string;
|
||||
cleanup: () => void;
|
||||
surfaces: PluginSurfaceContribution[];
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
attachmentSources: PluginAttachmentSourceContribution[];
|
||||
}
|
||||
|
||||
export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
serverId: string;
|
||||
clientBundle: string;
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
};
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Code2,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Blocks,
|
||||
} from "lucide-react-native";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
@@ -108,6 +109,7 @@ import {
|
||||
HostWorkspacesPage,
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
||||
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
@@ -191,6 +193,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
||||
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
|
||||
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
|
||||
{ id: "plugins", labelKey: "settings.hostSections.plugins", icon: Blocks },
|
||||
];
|
||||
|
||||
function renderHostSettingsContent(
|
||||
@@ -216,6 +219,8 @@ function renderHostSettingsContent(
|
||||
return <HostUsagePage serverId={view.serverId} />;
|
||||
case "terminals":
|
||||
return <HostTerminalsPage serverId={view.serverId} />;
|
||||
case "plugins":
|
||||
return <HostPluginsPage serverId={view.serverId} />;
|
||||
case "host":
|
||||
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePluginPageState } from "./plugins-page-state";
|
||||
|
||||
describe("resolvePluginPageState", () => {
|
||||
it.each([
|
||||
["offline", false, false, false, false, 0],
|
||||
["unsupported", true, false, false, false, 0],
|
||||
["loading", true, true, true, false, 0],
|
||||
["error", true, true, false, true, 0],
|
||||
["empty", true, true, false, false, 0],
|
||||
["ready", true, true, false, false, 1],
|
||||
] as const)("resolves %s", (expected, connected, supported, loading, error, pluginCount) => {
|
||||
expect(resolvePluginPageState({ connected, supported, loading, error, pluginCount })).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export type PluginPageState = "offline" | "unsupported" | "loading" | "error" | "empty" | "ready";
|
||||
|
||||
export function resolvePluginPageState(input: {
|
||||
connected: boolean;
|
||||
supported: boolean;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
pluginCount: number;
|
||||
}): PluginPageState {
|
||||
if (!input.connected) return "offline";
|
||||
if (!input.supported) return "unsupported";
|
||||
if (input.loading) return "loading";
|
||||
if (input.error) return "error";
|
||||
return input.pluginCount === 0 ? "empty" : "ready";
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import React, { type ReactElement } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginListItem } from "@getpaseo/protocol/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HostPluginsPage } from "./plugins-page";
|
||||
|
||||
void testI18n;
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
connected: true,
|
||||
supported: true,
|
||||
client: null as DaemonClient | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => runtime.client,
|
||||
useHostRuntimeIsConnected: () => runtime.connected,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: () => runtime.supported,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-reanimated", () => ({
|
||||
default: { View: "div" },
|
||||
Easing: { ease: "ease", inOut: (value: unknown) => value },
|
||||
interpolateColor: (value: number, _input: number[], output: string[]) =>
|
||||
value >= 1 ? output[1] : output[0],
|
||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||
useDerivedValue: (factory: () => unknown) => ({ value: factory() }),
|
||||
withTiming: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const ReactModule = await vi.importActual<typeof import("react")>("react");
|
||||
const { ScrollView, TextInput } =
|
||||
await vi.importActual<typeof import("react-native")>("react-native");
|
||||
return {
|
||||
BottomSheetBackdrop: () => null,
|
||||
BottomSheetModal: ReactModule.forwardRef(() => null),
|
||||
BottomSheetScrollView: ScrollView,
|
||||
BottomSheetTextInput: TextInput,
|
||||
useBottomSheetInternal: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
function never<T>(): Promise<T> {
|
||||
return new Promise<T>(() => undefined);
|
||||
}
|
||||
|
||||
function plugin(enabled = true): PluginListItem {
|
||||
return {
|
||||
id: "example",
|
||||
path: "/plugins/example",
|
||||
enabled,
|
||||
status: enabled ? "running" : "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function createClient() {
|
||||
return {
|
||||
on: vi.fn(() => () => undefined),
|
||||
getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })),
|
||||
patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })),
|
||||
listPlugins: vi.fn(async (): Promise<PluginListItem[]> => []),
|
||||
inspectDirectoryPlugin: vi.fn(async () => ({ id: "example" })),
|
||||
installDirectoryPlugin: vi.fn(async () => plugin()),
|
||||
reloadPlugin: vi.fn(async () => plugin()),
|
||||
enablePlugin: vi.fn(async () => plugin()),
|
||||
disablePlugin: vi.fn(async () => plugin(false)),
|
||||
removePlugin: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
type PluginClient = ReturnType<typeof createClient>;
|
||||
|
||||
function renderPage(client: PluginClient | null): void {
|
||||
runtime.client = client as unknown as DaemonClient | null;
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const element: ReactElement = (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HostPluginsPage serverId="host-a" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
render(element);
|
||||
}
|
||||
|
||||
describe("HostPluginsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
runtime.connected = true;
|
||||
runtime.supported = true;
|
||||
runtime.client = null;
|
||||
vi.stubGlobal(
|
||||
"confirm",
|
||||
vi.fn(() => true),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the offline state", () => {
|
||||
runtime.connected = false;
|
||||
renderPage(null);
|
||||
|
||||
expect(screen.getByRole("alert").textContent).toContain("Plugin host is offline");
|
||||
});
|
||||
|
||||
it("renders a catalog error and retries through the real query", async () => {
|
||||
const client = createClient();
|
||||
client.listPlugins.mockRejectedValueOnce(new Error("catalog exploded"));
|
||||
renderPage(client);
|
||||
|
||||
expect(await screen.findByText("Unable to load plugins")).toBeDefined();
|
||||
expect(screen.getByText("catalog exploded")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => expect(client.listPlugins).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByText("No plugins configured")).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["reloadPlugin", "Reload", "Reloading…", true],
|
||||
["disablePlugin", "Disable", "Disabling…", true],
|
||||
["enablePlugin", "Enable", "Enabling…", false],
|
||||
["removePlugin", "Remove", "Removing…", true],
|
||||
] as const)("renders %s pending on its plugin row", async (method, trigger, pending, enabled) => {
|
||||
const client = createClient();
|
||||
client.listPlugins.mockResolvedValue([plugin(enabled)]);
|
||||
client[method].mockImplementation(() => never<never>());
|
||||
renderPage(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: trigger }));
|
||||
|
||||
const pendingControl = await screen.findByRole("button", { name: pending });
|
||||
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(client[method]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders install pending through the real form and mutation", async () => {
|
||||
const client = createClient();
|
||||
client.installDirectoryPlugin.mockImplementation(() => never<never>());
|
||||
renderPage(client);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Plugin directory"), {
|
||||
target: { value: "/plugins/example" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install directory" }));
|
||||
|
||||
const pendingControl = await screen.findByRole("button", { name: "Installing…" });
|
||||
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
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 } from "@getpaseo/protocol/messages";
|
||||
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,
|
||||
}: {
|
||||
plugin: PluginListItem;
|
||||
clientError?: string;
|
||||
pending: boolean;
|
||||
pendingAction?: PluginRowAction;
|
||||
onAction(action: PluginRowAction, plugin: PluginListItem): void;
|
||||
}) {
|
||||
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 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 (
|
||||
<View style={styles.pluginRow} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.pluginTitle}>
|
||||
<Text style={settingsStyles.rowTitle}>{plugin.id}</Text>
|
||||
<StatusBadge label={statusLabel} variant={badgeVariant} />
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>{plugin.path}</Text>
|
||||
{clientError || plugin.error ? (
|
||||
<Text style={styles.error}>{clientError ?? plugin.error}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button variant="outline" size="sm" onPress={reload} disabled={pending || !plugin.enabled}>
|
||||
{pendingAction === "reload"
|
||||
? t("settings.plugins.actions.reloading")
|
||||
: t("settings.plugins.actions.reload")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={toggle} disabled={pending}>
|
||||
{toggleLabel}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={remove} disabled={pending}>
|
||||
{pendingAction === "remove"
|
||||
? t("settings.plugins.actions.removing")
|
||||
: t("settings.plugins.actions.remove")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "pluginManagement");
|
||||
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 [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<string>;
|
||||
}) => 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]);
|
||||
|
||||
if (pageState === "offline") {
|
||||
return (
|
||||
<Alert
|
||||
variant="warning"
|
||||
title={t("settings.plugins.states.offlineTitle")}
|
||||
description={t("settings.plugins.states.offlineDescription")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (pageState === "unsupported") {
|
||||
return <Alert variant="warning" title={t("settings.plugins.states.updateTitle")} />;
|
||||
}
|
||||
|
||||
let catalogContent = <Alert title={t("settings.plugins.states.loading")} />;
|
||||
if (pageState === "error") {
|
||||
catalogContent = (
|
||||
<Alert
|
||||
variant="error"
|
||||
title={t("settings.plugins.states.errorTitle")}
|
||||
description={errorMessage(plugins.error)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={retry}>
|
||||
{t("settings.plugins.states.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
} else if (pageState !== "loading") {
|
||||
catalogContent = (
|
||||
<View style={settingsStyles.card}>
|
||||
{plugins.data?.length ? (
|
||||
plugins.data.map((plugin) => {
|
||||
const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id);
|
||||
const pending = mutation.isPending && mutation.variables?.pluginId === plugin.id;
|
||||
return (
|
||||
<PluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
clientError={clientError}
|
||||
pending={mutation.isPending}
|
||||
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
|
||||
onAction={action}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<View style={styles.empty}>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.plugins.states.empty")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title={t("settings.plugins.title")}>
|
||||
<Alert
|
||||
variant="warning"
|
||||
title={t("settings.plugins.trustedTitle")}
|
||||
description={t("settings.plugins.trustedDescription")}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.plugins.globalTitle")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.plugins.globalHint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={config?.pluginsEnabled === true}
|
||||
onValueChange={toggleGlobal}
|
||||
disabled={mutation.isPending}
|
||||
accessibilityLabel={t("settings.plugins.globalTitle")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[settingsStyles.card, styles.install]}>
|
||||
<Field label={t("settings.plugins.directoryLabel")}>
|
||||
<FormTextInput
|
||||
initialValue=""
|
||||
resetKey={directoryResetKey}
|
||||
onChangeText={setDirectory}
|
||||
onBlur={prefillManifestId}
|
||||
placeholder={t("settings.plugins.directoryPlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!mutation.isPending}
|
||||
accessibilityLabel={t("settings.plugins.directoryLabel")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("settings.plugins.idLabel")} hint={t("settings.plugins.idHint")}>
|
||||
<FormTextInput
|
||||
initialValue={pluginId}
|
||||
resetKey={pluginIdResetKey}
|
||||
onChangeText={setPluginId}
|
||||
placeholder={t("settings.plugins.idPlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!mutation.isPending}
|
||||
accessibilityLabel={t("settings.plugins.idLabel")}
|
||||
/>
|
||||
</Field>
|
||||
<Button onPress={install} disabled={!directory.trim() || mutation.isPending}>
|
||||
{mutation.isPending && mutation.variables?.action === "install"
|
||||
? t("settings.plugins.installing")
|
||||
: t("settings.plugins.install")}
|
||||
</Button>
|
||||
</View>
|
||||
{feedback ? (
|
||||
<Alert
|
||||
variant={feedback.kind}
|
||||
title={feedback.message}
|
||||
testID="plugin-management-feedback"
|
||||
/>
|
||||
) : null}
|
||||
{catalogContent}
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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" },
|
||||
}));
|
||||
@@ -71,6 +71,34 @@ describe("draft-store lifecycle", () => {
|
||||
});
|
||||
|
||||
describe("draft-store normalization", () => {
|
||||
it("preserves plugin resources when hydrating a draft", () => {
|
||||
const attachment = {
|
||||
kind: "plugin_resource" as const,
|
||||
pluginId: "linear",
|
||||
sourceId: "issues",
|
||||
sourceTitle: "Linear issue",
|
||||
sourceIcon: "CircleDot",
|
||||
item: {
|
||||
id: "issue-uuid",
|
||||
identifier: "ENG-123",
|
||||
title: "Plugin attachments",
|
||||
subtitle: "In progress",
|
||||
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
|
||||
text: "Linear issue ENG-123: Plugin attachments",
|
||||
resourceType: "issue",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
toDraftInputIfReady({
|
||||
input: { text: "Implement this", attachments: [attachment] },
|
||||
lifecycle: "active",
|
||||
updatedAt: 1,
|
||||
version: 1,
|
||||
}),
|
||||
).toEqual({ text: "Implement this", attachments: [attachment] });
|
||||
});
|
||||
|
||||
it("preserves uploaded file attachments when hydrating a draft", () => {
|
||||
const attachment = {
|
||||
kind: "file" as const,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AttachmentMetadata,
|
||||
type UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { PluginResourceComposerAttachmentSchema } from "@/plugins/attachments";
|
||||
import { z } from "zod";
|
||||
|
||||
export const DRAFT_STORE_VERSION = 5;
|
||||
@@ -95,6 +96,7 @@ export const UserComposerAttachmentSchema: z.ZodType<UserComposerAttachment> = z
|
||||
z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }),
|
||||
z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }),
|
||||
z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }),
|
||||
PluginResourceComposerAttachmentSchema,
|
||||
z.strictObject({
|
||||
kind: z.literal("github_pr"),
|
||||
item: ChangeRequestItemSchema,
|
||||
|
||||
@@ -517,6 +517,7 @@ export const HOST_SECTION_SLUGS = [
|
||||
"providers",
|
||||
"usage",
|
||||
"terminals",
|
||||
"plugins",
|
||||
"host",
|
||||
] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user