Files
dbx/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue
T

629 lines
27 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AlertTriangle, Loader2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
import {
PluginHostBridge,
pluginSandboxDocument,
PLUGIN_SAVE_CHUNK_BYTES,
type PluginBridgeTheme,
type PluginFileHandleMeta,
type PluginFileReadChunk,
type PluginFileWriteResult,
type PluginPickFilesOptions,
type PluginSaveFileRequest,
type PluginSaveFileResult,
type PluginWorkbenchContext,
} from "@/lib/plugins/pluginHostBridge";
import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance";
import { downloadPluginFile, cancelPluginDownload } from "@/lib/plugins/pluginFileDownload";
import type { InstalledPlugin, PluginUiContribution } from "@/types/database";
import { useI18n } from "vue-i18n";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { useConnectionStore } from "@/stores/connectionStore";
const props = withDefaults(
defineProps<{
plugin: InstalledPlugin;
contribution: PluginUiContribution;
context?: PluginWorkbenchContext;
}>(),
{ context: () => ({}) },
);
const emit = defineEmits<{
ready: [];
error: [message: string];
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }];
openFilesystem: [pluginId: string, providerId: string, context?: PluginWorkbenchContext];
closeTab: [];
}>();
const { t, locale: appLocale } = useI18n();
const { isDark, themeRevision } = useTheme();
const settingsStore = useSettingsStore();
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;
let disposed = false;
let loadGeneration = 0;
// --- Plugin file-transfer bridge (native dialogs + OS file drops) ---------
// The sandboxed iframe cannot reach local files, so handles live here: Tauri
// handles wrap the plugin_file registry in Rust (`t<n>` ids); the web host
// keeps File objects and in-memory save buffers (`w<n>` ids). Only paths that
// came from a native dialog or an OS drop reach plugin_file_open — never a
// plugin-supplied string.
let webFileSequence = 0;
const webPickedFiles = new Map<string, File>();
const webSaveBuffers = new Map<string, { name: string; contentType: string; chunks: Map<number, Uint8Array> }>();
const openTauriHandles = new Set<number>();
const tauriHandlePrefix = "t";
const webHandlePrefix = "w";
function encodeBytesBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
function parseHandleId(handleId: string): { source: "tauri" | "web"; numericId: number } {
if (handleId.startsWith(tauriHandlePrefix)) return { source: "tauri", numericId: Number(handleId.slice(tauriHandlePrefix.length)) };
if (handleId.startsWith(webHandlePrefix)) return { source: "web", numericId: Number(handleId.slice(webHandlePrefix.length)) };
throw new Error("Unknown file handle");
}
/** Loaded lazily: the static tauri module drags side-effectful imports (i18n boot)
* into specs that mock the whole backend layer. */
async function tauriFileApi() {
return import("@/lib/backend/tauri");
}
async function openTauriPluginFile(pluginId: string, path: string, write: boolean): Promise<PluginFileHandleMeta> {
const { openPluginLocalFile } = await tauriFileApi();
const handle = await openPluginLocalFile(pluginId, path, write);
// Track read AND write handles: unmount must reclaim both (leaked fds also
// burn the shared 64-handle registry quota).
openTauriHandles.add(handle.handleId);
return { handleId: `${tauriHandlePrefix}${handle.handleId}`, name: handle.name, size: handle.size, contentType: handle.contentType };
}
async function pickPluginFiles(pluginId: string, options: PluginPickFilesOptions): Promise<PluginFileHandleMeta[]> {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ multiple: options.multiple === true });
const paths = Array.isArray(selected) ? selected : selected ? [selected] : [];
const files: PluginFileHandleMeta[] = [];
for (const path of paths) {
try {
files.push(await openTauriPluginFile(pluginId, path, false));
} catch (error) {
console.warn("[DBX][plugin-workbench:pick]", error);
}
}
return files;
}
// Web host: a top-document file input still works there (no sandbox).
const selection = await new Promise<FileList | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = options.multiple === true;
input.style.display = "none";
// The picker fires no change event on cancel; without this the pick
// promise hangs forever and the plugin's upload waits on nothing.
input.addEventListener("cancel", () => {
input.remove();
resolve(null);
});
input.addEventListener("change", () => {
input.remove();
resolve(input.files);
});
document.body.appendChild(input);
input.click();
});
const files: PluginFileHandleMeta[] = [];
for (const file of Array.from(selection || [])) {
const handleId = `${webHandlePrefix}${++webFileSequence}`;
webPickedFiles.set(handleId, file);
files.push({ handleId, name: file.name, size: file.size, contentType: file.type || "application/octet-stream" });
}
return files;
}
async function readPluginFileChunkById(pluginId: string, handleId: string, offset: number, length?: number): Promise<PluginFileReadChunk> {
const parsed = parseHandleId(handleId);
if (parsed.source === "tauri") {
const { readPluginLocalFileChunk } = await tauriFileApi();
return readPluginLocalFileChunk(pluginId, parsed.numericId, offset, length);
}
const file = webPickedFiles.get(handleId);
if (!file) throw new Error("Unknown file handle");
const slice = file.slice(offset, offset + (length ?? PLUGIN_SAVE_CHUNK_BYTES));
const bytes = new Uint8Array(await slice.arrayBuffer());
return { dataBase64: encodeBytesBase64(bytes), length: bytes.byteLength, eof: offset + bytes.byteLength >= file.size };
}
// --- Plugin UI storage bridge ---------------------------------------------
// Native hosts persist to `plugin-data/<id>/ui-storage.json` through Rust; the
// web host has no plugin-data tree, so entries fall back to the top document's
// localStorage under a per-plugin prefix (same isolation, same JSON values).
function webStorageKey(pluginId: string, key: string): string {
return `dbx-plugin-storage:${pluginId}:${key}`;
}
async function getPluginStorage(pluginId: string, key: string): Promise<unknown> {
if (isTauriRuntime()) {
const { getPluginUiStorage } = await tauriFileApi();
return getPluginUiStorage(pluginId, key);
}
const raw = localStorage.getItem(webStorageKey(pluginId, key));
return raw === null ? null : (JSON.parse(raw) as unknown);
}
async function setPluginStorage(pluginId: string, key: string, value: unknown): Promise<void> {
if (isTauriRuntime()) {
const { setPluginUiStorage } = await tauriFileApi();
return setPluginUiStorage(pluginId, key, value);
}
localStorage.setItem(webStorageKey(pluginId, key), JSON.stringify(value === undefined ? null : value));
}
async function deletePluginStorage(pluginId: string, key: string): Promise<void> {
if (isTauriRuntime()) {
const { deletePluginUiStorage } = await tauriFileApi();
return deletePluginUiStorage(pluginId, key);
}
localStorage.removeItem(webStorageKey(pluginId, key));
}
async function beginPluginFileSave(pluginId: string, request: { name?: string; contentType?: string; size?: number }): Promise<{ handleId: string; chunkBytes: number } | null> {
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const fileName = request.name || "download.bin";
const extension = fileName.includes(".") ? fileName.split(".").pop() : "";
const path = await save({
defaultPath: fileName,
filters: extension ? [{ name: extension.toUpperCase(), extensions: [extension] }] : undefined,
});
if (!path) return null;
// Route through openTauriPluginFile so the write handle joins
// openTauriHandles: a beginSave the plugin abandons must still be
// reclaimed on unmount instead of burning the shared registry quota.
const handle = await openTauriPluginFile(pluginId, path, true);
return { handleId: handle.handleId, chunkBytes: PLUGIN_SAVE_CHUNK_BYTES };
}
const handleId = `${webHandlePrefix}${++webFileSequence}`;
webSaveBuffers.set(handleId, { name: request.name || "download.bin", contentType: request.contentType || "application/octet-stream", chunks: new Map() });
return { handleId, chunkBytes: PLUGIN_SAVE_CHUNK_BYTES };
}
async function writePluginFileChunkById(pluginId: string, handleId: string, offset: number, bytes: Uint8Array): Promise<PluginFileWriteResult> {
const parsed = parseHandleId(handleId);
if (parsed.source === "tauri") {
const { writePluginLocalFileChunk } = await tauriFileApi();
return writePluginLocalFileChunk(pluginId, parsed.numericId, offset, encodeBytesBase64(bytes));
}
const buffer = webSaveBuffers.get(handleId);
if (!buffer) throw new Error("Unknown file handle");
buffer.chunks.set(offset, bytes);
return { written: bytes.byteLength, nextOffset: offset + bytes.byteLength };
}
async function finishPluginFileSave(pluginId: string, handleId: string): Promise<void> {
const parsed = parseHandleId(handleId);
if (parsed.source === "tauri") {
const { closePluginLocalFile } = await tauriFileApi();
openTauriHandles.delete(parsed.numericId);
await closePluginLocalFile(pluginId, parsed.numericId);
return;
}
const buffer = webSaveBuffers.get(handleId);
if (!buffer) throw new Error("Unknown file handle");
webSaveBuffers.delete(handleId);
const ordered = [...buffer.chunks.entries()].sort(([left], [right]) => left - right);
const size = ordered.reduce((total, [, chunk]) => total + chunk.byteLength, 0);
const assembled = new Uint8Array(size);
let cursor = 0;
for (const [, chunk] of ordered) {
assembled.set(chunk, cursor);
cursor += chunk.byteLength;
}
const url = URL.createObjectURL(new Blob([assembled.buffer as ArrayBuffer], { type: buffer.contentType }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = buffer.name;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function closePluginFileHandleById(pluginId: string, handleId: string): Promise<void> {
const parsed = parseHandleId(handleId);
if (parsed.source === "tauri") {
const { closePluginLocalFile } = await tauriFileApi();
openTauriHandles.delete(parsed.numericId);
await closePluginLocalFile(pluginId, parsed.numericId);
return;
}
webPickedFiles.delete(handleId);
webSaveBuffers.delete(handleId);
}
function disposeLocalFileHandles(): void {
for (const handleId of openTauriHandles)
tauriFileApi()
.then(({ closePluginLocalFile }) => closePluginLocalFile(props.plugin.manifest.id, handleId))
.catch(() => undefined);
openTauriHandles.clear();
webPickedFiles.clear();
webSaveBuffers.clear();
}
// --- OS file-drop routing (Tauri captures drops at the webview layer) -----
// Tauri's native drag-drop pipeline hands file PATHS to the host page; HTML5
// drop events with real files never reach web content, and plugin iframes
// especially. The webview-level `dbx:tauri-file-drop` event (see useFileDrop)
// carries the physical-window position, so the workbench claims drops whose
// converted CSS point lands on its iframe: preventDefault stops the host's
// open-as-database fallback, and the paths are opened into handles that the
// plugin receives through the bridge.
interface TauriFileDropPayload {
type: "enter" | "over" | "drop" | "leave";
paths?: string[];
position?: { x: number; y: number };
}
let dropDragActive = false;
function forwardDragState(active: boolean): void {
dropDragActive = active;
bridge?.forwardDragState(active);
}
function onHostFileDrop(event: Event): void {
const payload = (event as CustomEvent<TauriFileDropPayload>).detail;
if (!payload || payload.type === "leave") {
if (dropDragActive) forwardDragState(false);
return;
}
const frame = iframe.value;
const position = payload.position;
if (!frame || !position) {
if (dropDragActive) forwardDragState(false);
return;
}
// Overlay titlebar: the webview starts at the window origin, so physical
// window coordinates divide straight into CSS pixels via devicePixelRatio.
const scale = window.devicePixelRatio || 1;
if (document.elementFromPoint(position.x / scale, position.y / scale) !== frame) {
if (dropDragActive) forwardDragState(false);
return;
}
// Claim the drop so the host fallback (open as SQL/database) does not run.
event.preventDefault();
if (payload.type !== "drop") {
if (!dropDragActive) forwardDragState(true);
return;
}
forwardDragState(false);
const paths = (payload.paths || []).filter((path) => typeof path === "string" && path);
if (!paths.length || !bridge) return;
void (async () => {
const files: PluginFileHandleMeta[] = [];
for (const path of paths) {
try {
files.push(await openTauriPluginFile(props.plugin.manifest.id, path, false));
} catch (error) {
console.error("[DBX][plugin-workbench:drop]", error);
}
}
if (files.length) bridge?.forwardFileDrop(files);
})();
}
const title = computed(() => `${props.plugin.manifest.name} · ${props.contribution.label}`);
/** Collect resolved DBX design tokens so the sandbox can theme itself with the same values. */
function currentBridgeTheme(): PluginBridgeTheme {
const tokens: Record<string, string> = {};
if (typeof document !== "undefined") {
const style = getComputedStyle(document.documentElement);
for (const name of style) {
if (!name.startsWith("--") || name.startsWith("--dbx-")) continue;
if (/^--(color|radius|font)/.test(name)) {
const value = style.getPropertyValue(name).trim();
if (value) tokens[name] = value;
}
}
}
return {
appearance: isDark.value ? "dark" : "light",
tokens,
editor: buildPluginEditorAppearance(settingsStore.editorSettings),
};
}
function createBridge() {
bridge?.dispose();
bridge = new PluginHostBridge(
props.plugin,
props.contribution,
props.context,
() => iframe.value?.contentWindow || null,
{
invoke: api.invokePlugin,
notify: api.notifyPlugin,
sendBinary: api.sendPluginBinary,
readAsset: api.readPluginUiAsset,
openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options),
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId),
// Both plan calls carry the plugin's declared `host.plans:read` gate in the
// bridge; the backend owns EXPLAIN generation, the timeout, and the plan cap.
getPlanCapabilities: (connectionId) => api.getPluginPlanCapabilities(connectionId),
explainPlan: (request) => api.getPluginEstimatedPlan(request),
closeTab: () => emit("closeTab"),
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
downloadFile: isTauriRuntime() ? downloadPluginFile : undefined,
cancelDownload: isTauriRuntime() ? cancelPluginDownload : undefined,
copyText: (_pluginId, text) => copyToClipboard(text),
pickFiles: (pluginId, options) => pickPluginFiles(pluginId, options),
readFileChunk: (pluginId, handleId, offset, length) => readPluginFileChunkById(pluginId, handleId, offset, length),
beginFileSave: (pluginId, request) => beginPluginFileSave(pluginId, request),
writeFileChunk: (pluginId, handleId, offset, bytes) => writePluginFileChunkById(pluginId, handleId, offset, bytes),
finishFileSave: (pluginId, handleId) => finishPluginFileSave(pluginId, handleId),
closeFileHandle: (pluginId, handleId) => closePluginFileHandleById(pluginId, handleId),
storageGet: (pluginId, key) => getPluginStorage(pluginId, key),
storageSet: (pluginId, key, value) => setPluginStorage(pluginId, key, value),
storageDelete: (pluginId, key) => deletePluginStorage(pluginId, key),
},
appLocale.value,
currentBridgeTheme(),
);
// An iframe reload (F5 / webview restart) drops the plugin sidecar's
// in-memory connection registry while the host still holds the connection
// open. Re-push the connection config through the same path as a sidebar
// open before the plugin receives its fresh init, so it can reconnect
// without the user reopening the connection from the sidebar.
bridge.onReinit = async () => {
const connectionId = props.context?.connectionId;
if (!connectionId) return;
await useConnectionStore().repushPluginConnection(connectionId);
};
}
/** Keep a plugin-supplied name from smuggling path separators or traversal into the save dialog. */
function safeFileName(value: string | undefined): string {
const base = (value || "").split(/[\\/]/).pop() || "";
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
const cleaned = base.replace(/[\u0000-\u001f<>:"|?*]+/g, "").trim();
return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "download.bin";
}
/**
* Native save dialog + disk write for plugin downloads. The sandboxed iframe
* cannot trigger downloads itself (WKWebView cancels blob-anchor navigations
* when no host download handler is registered), so the bytes travel through
* the bridge and the host persists them. Resolves null when the user cancels.
*/
async function savePluginFile(request: PluginSaveFileRequest, data: Uint8Array): Promise<PluginSaveFileResult | null> {
const fileName = safeFileName(request.fileName);
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeFile } = await import("@tauri-apps/plugin-fs");
const extension = fileName.includes(".") ? (fileName.split(".").pop() as string) : "";
const path = await save({
defaultPath: fileName,
filters: extension ? [{ name: extension.toUpperCase(), extensions: [extension] }] : undefined,
});
if (!path) return null;
await writeFile(path, data);
return { path };
}
// Web host: the sandboxed iframe cannot download, but the host page can.
// Transferred buffers are plain ArrayBuffers (SharedArrayBuffer cannot cross postMessage).
const url = URL.createObjectURL(new Blob([data.buffer as ArrayBuffer], { type: request.contentType || "application/octet-stream" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
return { path: fileName };
}
function localUiAssetPath(source: string): string | undefined {
const trimmed = source.trim();
if (!trimmed || /^(?:blob:|data:|https?:|\/\/)/i.test(trimmed)) return undefined;
try {
const resolved = new URL(trimmed, "https://dbx-plugin.invalid/");
if (resolved.origin !== "https://dbx-plugin.invalid") return undefined;
const path = decodeURIComponent(resolved.pathname).replace(/^\/+/, "");
if (!path || path.split("/").some((segment) => segment === "..")) return undefined;
return path;
} catch {
return undefined;
}
}
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<{ html: string; entryDirectory: string }> {
const document = new DOMParser().parseFromString(html, "text/html");
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
// Dynamic-import chunks and CSS url() references live next to the entry
// script; its directory is the <base> the sandbox document needs to resolve
// them through the dbx-plugin scheme.
let entryDirectory = "";
for (const resource of resources) {
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
const path = source ? localUiAssetPath(source) : undefined;
if (!path) continue;
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
const asset = await api.readPluginUiAsset(pluginId, path);
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
if (resource.tagName === "SCRIPT") {
const script = document.createElement("script");
for (const attribute of [...resource.attributes]) {
if (attribute.name !== "src") script.setAttribute(attribute.name, attribute.value);
}
script.textContent = content;
resource.replaceWith(script);
} else {
const style = document.createElement("style");
style.textContent = content;
resource.replaceWith(style);
}
}
return { html: document.documentElement.outerHTML, entryDirectory };
}
/**
* Base URL prefix for lazy-loaded plugin UI assets. wry serves custom schemes
* natively on WKWebView/webkit2gtk but maps them onto http(s) subdomains on
* WebView2, so the host page's own protocol picks the form the webview will
* actually request. The web host has no plugin asset protocol.
*/
function pluginUiBaseUrl(pluginId: string, entryDirectory: string): string | undefined {
if (!isTauriRuntime()) return undefined;
const origin = location.protocol === "http:" || location.protocol === "https:" ? `${location.protocol}//dbx-plugin.localhost/${pluginId}/` : `dbx-plugin://localhost/${pluginId}/`;
return entryDirectory ? `${origin}${entryDirectory}/` : origin;
}
async function loadWorkbench() {
const generation = ++loadGeneration;
bridge?.dispose();
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"));
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
if (disposed || generation !== loadGeneration) return;
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
const { html, entryDirectory } = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
if (disposed || generation !== loadGeneration) return;
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme(), {
baseUrl: pluginUiBaseUrl(props.plugin.manifest.id, entryDirectory),
});
await nextTick();
if (disposed || generation !== loadGeneration) return;
createBridge();
} catch (cause) {
if (disposed || generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
emit("error", error.value);
} finally {
if (!disposed && generation === loadGeneration) loading.value = false;
}
}
function onMessage(event: MessageEvent) {
bridge?.handleWindowMessage(event);
}
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");
}
onMounted(async () => {
window.addEventListener("message", onMessage);
document.addEventListener("dbx:tauri-file-drop", onHostFileDrop);
const unsubscribe = await api.subscribePluginEvents(
(event) => bridge?.forwardEvent(event),
(event) => bridge?.forwardBinary(event),
);
if (disposed) {
unsubscribe();
return;
}
unsubscribeEvents = unsubscribe;
await loadWorkbench();
});
// Identity changes require rebuilding the sandbox document; context and locale
// changes are pushed through the bridge so plugin UI state survives them.
watch(
() => [props.plugin.manifest.id, props.plugin.manifest.version, props.contribution.id] as const,
() => void loadWorkbench(),
);
watch(
() => props.context,
(context) => bridge?.updateContext(context ?? {}),
{ deep: true },
);
watch(appLocale, (locale) => bridge?.updateLocale(locale));
// 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()));
// Font families are mirrored onto root tokens by App.vue (writeRootToken) and
// reach live bridges through the themeRevision bump above. fontSize and the
// SQL editor syntax theme have no CSS-token carrier — watch them explicitly.
watch(
() => [settingsStore.editorSettings.fontSize, settingsStore.editorSettings.theme],
() => bridge?.updateTheme(currentBridgeTheme()),
);
onBeforeUnmount(() => {
disposed = true;
loadGeneration += 1;
bridge?.dispose();
bridge = undefined;
window.removeEventListener("message", onMessage);
document.removeEventListener("dbx:tauri-file-drop", onHostFileDrop);
disposeLocalFileHandles();
unsubscribeEvents?.();
});
</script>
<template>
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
<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" allow="clipboard-write" 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>