fix(plugins): stabilize plugin integration

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-14 21:03:47 +08:00
1 parent b70d6da227
commit 3d1b84831f
76 files changed
+3846 -431

No files matched your search

@@ -1,11 +1,21 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import PluginConnectionFields from "./PluginConnectionFields.vue";
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
const invokePluginMock = vi.fn();
vi.mock("@/lib/backend/api", () => ({
listLocalSshKeys: vi.fn(async () => [
{ path: "/home/dev/.ssh/id_ed25519", algorithm: "ssh-ed25519", fingerprint: "SHA256:abc", hasPassphrase: false },
{ path: "/home/dev/.ssh/id_rsa", algorithm: "ssh-rsa", fingerprint: "", hasPassphrase: true },
]),
invokePlugin: (...args: unknown[]) => invokePluginMock(...args),
}));
const mountedApps: App[] = [];
const contribution: PluginConnectionProviderContribution = {
@@ -16,16 +26,6 @@ const contribution: PluginConnectionProviderContribution = {
fields: [
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
{ key: "password", label: "Password", type: "password" },
{
key: "protocol",
label: "Protocol",
type: "radio",
default: "https",
options: [
{ label: "HTTPS", value: "https" },
{ label: "HTTP", value: "http" },
],
},
],
};
@@ -62,6 +62,39 @@ afterEach(() => {
});
describe("PluginConnectionFields", () => {
it("preserves multiline secret input and masks it again after its branch is reopened", async () => {
const provider: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "pem.connection",
label: "PEM",
database_type: "example",
fields: [
{ key: "mode", label: "Mode", type: "text", default: "tls" },
{ key: "key", label: "Private key", type: "textarea", binding: "secret", visible_when: { field: "mode", one_of: ["tls"] } },
],
};
const state = await mountContribution(provider, {});
const textarea = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
const pem = "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n";
expect(textarea.classList.contains("secret-masked")).toBe(true);
textarea.value = pem;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect(state.values.key).toBe(pem);
textarea.parentElement!.querySelector<HTMLButtonElement>("button")!.click();
await nextTick();
expect(textarea.classList.contains("secret-masked")).toBe(false);
expect(textarea.value).toBe(pem);
state.values = { ...state.values, mode: "plain" };
await nextTick();
expect(document.querySelector("#pem-connection-key")).toBeNull();
state.values = { ...state.values, mode: "tls" };
await nextTick();
const reopened = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
expect(reopened.value).toBe(pem);
expect(reopened.classList.contains("secret-masked")).toBe(true);
});
it("renders declared fields and emits immutable model updates", async () => {
const state = await mountFields({ password: "secret" });
const hostInput = document.querySelector<HTMLInputElement>("#example-connection-host");
@@ -99,15 +132,199 @@ describe("PluginConnectionFields", () => {
expect(document.querySelector(".rounded-lg.border")).toBeNull();
});
it("renders radio fields and emits the selected option", async () => {
const state = await mountFields();
const radios = Array.from(document.querySelectorAll<HTMLInputElement>("input[type='radio']"));
it("hides conditional fields until visible_when matches and marks required_when", async () => {
const conditional: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "cond.connection",
label: "Conditional",
database_type: "cond",
fields: [
{ key: "mode", label: "Mode", type: "text", default: "password" },
{ key: "private_key_path", label: "Private key", type: "text", visible_when: { field: "mode", one_of: ["key"] }, required_when: { field: "mode", one_of: ["key"] } },
],
};
const state = await mountContribution(conditional, {});
expect(radios).toHaveLength(2);
expect(radios[0]?.checked).toBe(true);
radios[1]?.click();
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
state.values = { mode: "key" };
await nextTick();
expect(state.values.protocol).toBe("http");
const keyInput = document.querySelector<HTMLInputElement>("#cond-connection-private_key_path");
expect(keyInput).not.toBeNull();
const label = document.querySelector('label[for="cond-connection-private_key_path"]');
expect(label?.textContent).toContain("*");
state.values = { mode: "password" };
await nextTick();
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
});
it("keeps grandchild fields hidden while an intermediate default-only value would match (kafka msk_region regression)", async () => {
// sasl_mechanism (PLAIN default, hidden without SASL) → oauth_token_source
// (msk_iam default) → msk_region: the nested default must not surface an
// always-required AWS region on a plain PLAINTEXT/SCRAM connection.
const kafka: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "kafka.connection",
label: "Kafka",
database_type: "kafka",
fields: [
{ key: "bootstrap_servers", label: "Bootstrap servers", type: "textarea", required: true },
{
key: "security_protocol",
label: "Security protocol",
type: "select",
default: "PLAINTEXT",
options: [
{ label: "PLAINTEXT", value: "PLAINTEXT" },
{ label: "SASL_SSL", value: "SASL_SSL" },
],
},
{
key: "sasl_mechanism",
label: "SASL mechanism",
type: "select",
default: "PLAIN",
options: [
{ label: "PLAIN", value: "PLAIN" },
{ label: "OAUTHBEARER", value: "OAUTHBEARER" },
],
visible_when: { field: "security_protocol", one_of: ["SASL_PLAINTEXT", "SASL_SSL"] },
},
{
key: "oauth_token_source",
label: "OAuth token source",
type: "select",
default: "msk_iam",
options: [
{ label: "MSK IAM", value: "msk_iam" },
{ label: "Static token", value: "static_token" },
],
visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] },
},
{ key: "msk_region", label: "MSK AWS region", type: "text", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] }, required_when: { field: "oauth_token_source", one_of: ["msk_iam"] } },
],
};
const state = await mountContribution(kafka, {}, "io.dbx.kafka");
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
state.values = { security_protocol: "SASL_SSL", sasl_mechanism: "OAUTHBEARER" };
await nextTick();
expect(document.querySelector("#kafka-connection-msk_region")).not.toBeNull();
const label = document.querySelector('label[for="kafka-connection-msk_region"]');
expect(label?.textContent).toContain("*");
// Selecting the static token source hides the region field again.
state.values = { sasl_mechanism: "OAUTHBEARER", oauth_token_source: "static_token" };
await nextTick();
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
});
it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => {
const withKey: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "keys.connection",
label: "Keys",
database_type: "keys",
fields: [{ key: "private_key_path", label: "Private key", type: "text" }],
};
const state = await mountContribution(withKey, {});
await flushAsync();
const select = document.querySelector<HTMLSelectElement>("select");
expect(select).not.toBeNull();
expect(select?.disabled).toBe(false);
const encryptedOption = select?.querySelector<HTMLOptionElement>('option[value="/home/dev/.ssh/id_rsa"]');
expect(encryptedOption?.textContent).toContain("id_rsa");
expect(encryptedOption?.textContent).toContain("ssh-rsa");
expect(encryptedOption?.textContent).toMatch(/encrypted|已加密/);
select!.value = "/home/dev/.ssh/id_rsa";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await nextTick();
expect(state.values.private_key_path).toBe("/home/dev/.ssh/id_rsa");
});
it("renders options_action fields as a dynamic select and keeps a stored value visible", async () => {
const dynamic: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "dyn.connection",
label: "Dyn",
database_type: "dyn",
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
};
invokePluginMock.mockResolvedValueOnce({
options: [
{ value: "p1", label: "Ops" },
{ value: "p2", label: "QA" },
],
});
// "ghost" is stored but no longer offered: the dropdown must keep it visible.
await mountContribution(dynamic, { sudo_profile: "ghost" }, "io.dbx.ssh");
await flushAsync();
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options");
expect(document.querySelector("input#dyn-connection-sudo_profile")).toBeNull();
const trigger = document.querySelector<HTMLButtonElement>('#dyn-connection-sudo_profile, button[role="combobox"]');
expect(trigger).not.toBeNull();
expect(trigger?.textContent).toContain("ghost");
});
it("falls back to the text input when the options_action call fails or returns nothing", async () => {
const dynamic: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "degrade.connection",
label: "Degrade",
database_type: "degrade",
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
};
invokePluginMock.mockRejectedValueOnce(new Error("method not registered"));
await mountContribution(dynamic, {}, "io.dbx.ssh");
await flushAsync();
const input = document.querySelector<HTMLInputElement>("input#degrade-connection-sudo_profile");
expect(input).not.toBeNull();
expect(input?.value).toBe("");
// An empty option list degrades the same way.
invokePluginMock.mockResolvedValueOnce({ options: [] });
const empty = { ...dynamic, id: "empty.connection", label: "Empty" };
await mountContribution(empty, {}, "io.dbx.ssh");
await flushAsync();
expect(document.querySelector("input#empty-connection-sudo_profile")).not.toBeNull();
});
});
async function flushAsync() {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function mountContribution(contribution: PluginConnectionProviderContribution, initialValues: Record<string, PluginFormFieldValue>, pluginId?: string) {
const state = reactive({ values: initialValues });
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () =>
h(PluginConnectionFields, {
contribution,
modelValue: state.values,
pluginId,
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
state.values = value;
},
});
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
return state;
}
@@ -1,25 +1,45 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import PasswordTextarea from "@/components/ui/PasswordTextarea.vue";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
import { pluginFieldIsRequired, pluginFieldIsVisible, type PluginFieldResolver } from "@/lib/plugins/pluginFieldConditions";
import { invokePlugin, listLocalSshKeys } from "@/lib/backend/api";
import type { LocalSshKey, PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldOption, PluginFormFieldValue } from "@/types/database";
const props = defineProps<{
contribution: PluginConnectionProviderContribution;
modelValue: Record<string, PluginFormFieldValue>;
hiddenBindings?: PluginFormFieldBinding[];
layout?: "stacked" | "connection-dialog";
pluginId?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
}>();
const { t } = useI18n();
const formValues = computed(() => props.modelValue);
const visibleFields = computed(() => props.contribution.fields.filter((field) => !field.binding || !props.hiddenBindings?.includes(field.binding)));
/** Value of any sibling field by key (condition evaluation reads current form values). */
function readFieldValue(key: string): PluginFormFieldValue {
const field = props.contribution.fields.find((candidate) => candidate.key === key);
return field ? (formValues.value[key] ?? field.default) : undefined;
}
const resolveSiblingField: PluginFieldResolver = (key) => props.contribution.fields.find((candidate) => candidate.key === key);
function fieldVisible(field: PluginFormField): boolean {
if (field.binding && props.hiddenBindings?.includes(field.binding)) return false;
return pluginFieldIsVisible(field, readFieldValue, resolveSiblingField);
}
function fieldRequired(field: PluginFormField): boolean {
return pluginFieldIsRequired(field, readFieldValue);
}
const visibleFields = computed(() => props.contribution.fields.filter((field) => fieldVisible(field)));
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
function fieldValue(field: PluginFormField): PluginFormFieldValue {
@@ -59,6 +79,103 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
if (field.type === "number") return undefined;
return "";
}
// ---------------------------------------------------------------------------
// Dynamic option lists (fields declaring `options_action`)
// ---------------------------------------------------------------------------
// A field may point `options_action` at a plugin method returning
// `{ options: [{ value, label }] }`. The host fetches it once per dialog and
// renders the field as a select; a missing plugin id, a failed call, or an
// empty result falls back to the declared text input (older hosts without
// this extension never fetch and simply keep the text field).
const dynamicOptions = ref<Record<string, PluginFormFieldOption[]>>({});
const dynamicOptionsRequested = ref(new Set<string>());
const optionsActionFields = computed(() => visibleFields.value.filter((field) => field.options_action));
watch(
optionsActionFields,
(fields) => {
for (const field of fields) {
const action = field.options_action;
if (!action || !props.pluginId || dynamicOptionsRequested.value.has(field.key)) continue;
dynamicOptionsRequested.value.add(field.key);
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action)
.then((result) => {
const options = Array.isArray(result?.options) ? result.options.filter((option) => option && option.value !== undefined) : [];
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: options };
})
.catch(() => {
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: [] };
});
}
},
{ immediate: true },
);
function selectOptionsFor(field: PluginFormField): PluginFormFieldOption[] | null {
if (!field.options_action) return null;
const options = dynamicOptions.value[field.key];
if (!options || options.length === 0) return null;
// Keep a stored value visible even when its profile disappeared so the
// dialog does not silently look "unset" on reopen.
const current = fieldValue(field);
if (current !== undefined && current !== "" && !options.some((option) => String(option.value) === String(current))) {
return [{ value: String(current), label: String(current) }, ...options];
}
return options;
}
// ---------------------------------------------------------------------------
// Local SSH key suggestions (Host API: private_key_path convenience picker)
// ---------------------------------------------------------------------------
const LOCAL_SSH_KEY_FIELD = "private_key_path";
function isLocalSshKeyField(field: PluginFormField): boolean {
return field.key === LOCAL_SSH_KEY_FIELD && (field.type === "text" || field.type === "password");
}
const hasLocalSshKeyField = computed(() => visibleFields.value.some((field) => isLocalSshKeyField(field)));
const localSshKeys = ref<LocalSshKey[]>([]);
const localSshKeysRequested = ref(false);
async function loadLocalSshKeys() {
if (localSshKeysRequested.value) return;
localSshKeysRequested.value = true;
try {
localSshKeys.value = await listLocalSshKeys();
} catch {
localSshKeys.value = [];
}
}
watch(
hasLocalSshKeyField,
(present) => {
if (present) void loadLocalSshKeys();
},
{ immediate: true },
);
function localSshKeyFile(path: string): string {
return path.replaceAll("\\", "/").split("/").pop() || path;
}
function localSshKeyLabel(key: LocalSshKey): string {
const algorithm = key.algorithm || "?";
return key.hasPassphrase ? `${localSshKeyFile(key.path)} · ${algorithm} · ${t("connection.pluginSshKeyEncrypted")}` : `${localSshKeyFile(key.path)} · ${algorithm}`;
}
function applyLocalSshKey(field: PluginFormField, event: Event) {
const select = event.target as HTMLSelectElement;
const selected = localSshKeys.value.find((key) => key.path === select.value);
select.value = "";
if (!selected) return;
updateField(field, selected.path);
}
</script>
<template>
@@ -75,11 +192,37 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
<div v-for="field in visibleFields" :key="field.key" :class="isConnectionDialogLayout ? 'col-span-full grid grid-cols-4 items-start gap-4' : 'space-y-1.5'">
<Label :for="fieldId(field)" :class="isConnectionDialogLayout ? 'justify-self-start pt-2 text-left' : 'text-xs'">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
<span v-if="fieldRequired(field)" class="text-destructive">*</span>
</Label>
<div :class="isConnectionDialogLayout ? 'col-span-3 min-w-0 space-y-1.5' : ''">
<Input v-if="field.type === 'text' || field.type === 'number'" :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
<template v-if="field.type === 'text' || field.type === 'number'">
<Select v-if="field.type === 'text' && selectOptionsFor(field)" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
<SelectValue :placeholder="field.placeholder" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in selectOptionsFor(field)!" :key="String(option.value)" :value="String(option.value)">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-else-if="isLocalSshKeyField(field)" class="flex items-center gap-1.5">
<Input :id="fieldId(field)" type="text" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
<select
class="h-9 w-40 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="localSshKeys.length === 0"
:title="t('connection.pluginSshKeyPickerPlaceholder')"
:aria-label="t('connection.pluginSshKeyPickerPlaceholder')"
@change="applyLocalSshKey(field, $event)"
>
<option value="" disabled selected>{{ localSshKeys.length ? t("connection.pluginSshKeyPickerPlaceholder") : t("connection.pluginSshKeyPickerEmpty") }}</option>
<option v-for="key in localSshKeys" :key="key.path" :value="key.path" :title="key.path">{{ localSshKeyLabel(key) }}</option>
</select>
</div>
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
</template>
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
<PasswordTextarea v-else-if="field.type === 'textarea' && field.binding === 'secret'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
<textarea
v-else-if="field.type === 'textarea'"
:id="fieldId(field)"
@@ -98,12 +241,6 @@ function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
</SelectItem>
</SelectContent>
</Select>
<div v-else-if="field.type === 'radio'" :id="fieldId(field)" class="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
<label v-for="option in field.options || []" :key="option.value" class="inline-flex cursor-pointer items-center gap-2 text-sm">
<input type="radio" :name="fieldId(field)" :value="option.value" :checked="String(fieldValue(field) ?? '') === option.value" class="size-4 accent-primary" @change="updateField(field, option.value)" />
<span>{{ option.label }}</span>
</label>
</div>
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
</div>
@@ -25,10 +25,14 @@ const emit = defineEmits<{
}>();
const { t, locale: appLocale } = useI18n();
const { isDark, activeCustomUiColors } = useTheme();
const { isDark, themeRevision } = useTheme();
const iframe = ref<HTMLIFrameElement>();
const source = ref("");
const loading = ref(true);
// Stays false until the iframe's load event: WKWebView paints a white canvas
// for a freshly inserted iframe before the sandbox document's first styled
// frame, so the themed overlay must keep covering the frame area until then.
const frameReady = ref(false);
const error = ref("");
let bridge: PluginHostBridge | undefined;
let unsubscribeEvents: (() => void) | undefined;
@@ -116,6 +120,7 @@ async function loadWorkbench() {
const generation = ++loadGeneration;
bridge = undefined;
loading.value = true;
frameReady.value = false;
error.value = "";
try {
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
@@ -132,7 +137,8 @@ async function loadWorkbench() {
if (disposed || generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
emit("error", error.value);
loading.value = false;
} finally {
if (!disposed && generation === loadGeneration) loading.value = false;
}
}
@@ -141,9 +147,18 @@ function onMessage(event: MessageEvent) {
}
function onFrameLoad() {
// The load event can precede the webview's first actual paint (notably on
// WKWebView); reveal after two animation frames, with a timer fallback
// because rAF stalls in occluded/background webviews. Guarded by generation
// so a stale callback from a rebuilt iframe can't lift the new overlay.
const generation = loadGeneration;
const reveal = () => {
if (!disposed && generation === loadGeneration) frameReady.value = true;
};
requestAnimationFrame(() => requestAnimationFrame(reveal));
setTimeout(reveal, 400);
bridge?.sendInit();
emit("ready");
loading.value = false;
}
onMounted(async () => {
@@ -172,7 +187,10 @@ watch(
{ deep: true },
);
watch(appLocale, (locale) => bridge?.updateLocale(locale));
watch([isDark, activeCustomUiColors], () => bridge?.updateTheme(currentBridgeTheme()), { deep: true });
// Keyed on the theme revision (bumped by applyTheme) so every theme change
// path — dark/light, palette switch, custom colors — re-pushes the resolved
// tokens; watching isDark/custom colors alone misses palette-only switches.
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
onBeforeUnmount(() => {
disposed = true;
@@ -185,14 +203,25 @@ onBeforeUnmount(() => {
<template>
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
<div v-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<iframe v-else-if="source" ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
<div v-if="loading && !error" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<template v-else>
<iframe ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
<!-- Cover until the frame has actually painted: the iframe stays mounted
underneath so its load event can fire (v-else on the overlay would
deadlock), it just isn't visible yet. Fully opaque so the covered
phase is visually identical to the host background, and faded out
instead of removed so the reveal is never a hard swap. -->
<div class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground transition-opacity duration-150 ease-out" :class="frameReady ? 'pointer-events-none opacity-0' : 'opacity-100'">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
</template>
</div>
</template>